1
0
mirror of https://github.com/ialley-workshop-open/uni-halo.git synced 2026-07-27 04:20:43 +08:00

15 Commits

Author SHA1 Message Date
小莫唐尼 af87beebff style: 统一项目内文字尺寸为xs替换11px
1. 调整微信登录按钮图标颜色格式为小写
2. 统一帖子卡片、文章详情页的文字大小为xs替代11px
2026-06-16 01:56:54 +08:00
小莫唐尼 9dc5aae164 style: 调整markdown列表内边距和分享按钮样式
1. 将ul和ol的左侧内边距从15px改为24px,统一列表缩进样式
2. 简化文章详情页上下导航的padding类,移除重复的pt-0
3. 优化分享按钮的样式类,合并border相关样式并移除冗余样式
2026-06-16 01:53:58 +08:00
小莫唐尼 a510875d38 style: 调整多处页面UI细节与默认状态
1. 将动态页面默认视图从时间线模式改为非时间线模式
2. 修复分享按钮的默认样式问题,移除多余边框和背景
3. 调整登录页分隔线上下间距与微信登录按钮高度
4. 隐藏文章卡片的分类信息展示
2026-06-16 01:44:17 +08:00
小莫唐尼 02c803f0c1 style(login,guest-panel): 优化表单UI和代码结构
1. 移除访客信息面板的冗余标签文本
2. 调整登录页样式顺序、类名和分隔线排版
3. 拆分长条件语句,新增返回按钮和自定义导航栏
4. 统一输入框背景色类名
2026-06-16 01:28:25 +08:00
小莫唐尼 a9e649e110 feat: 新增点赞、游客评论、分享等核心功能,重构页面逻辑
1. 新增全局点赞状态管理,使用metadata.name做key并持久化存储
2. 新增游客信息存储与评论面板,支持游客评论互动
3. 新增全局分享hook,为多个页面配置分享功能
4. 重构登录页面,支持登录/注册切换与游客访问模式
5. 合并注册页到登录页,简化路由结构
6. 优化剪贴板工具函数,支持自定义提示文本
7. 重构个人中心页面,支持游客信息管理
8. 修复相册页面标题与样式问题
9. 移除无用的备份文件与测试代码
2026-06-16 00:43:48 +08:00
小莫唐尼 1ba71ff43f feat(comment-panel): 新增回复目标原内容展示并优化回复提示样式
1. 扩展replyTarget类型新增rawContent字段用于存储原评论内容
2. 传递评论和回复的原始内容到回复处理函数
3. 在回复提示区展示被回复的原评论内容
4. 优化回复提示的排版布局,调整文字样式和间距
2026-06-15 23:03:34 +08:00
小莫唐尼 700eb10517 feat(comment-panel): 优化评论回复展示与交互细节
1. 新增查找被引用回复的工具函数
2. 优化加载状态的展示样式与文案
3. 调整回复列表间距与引用回复的渲染逻辑
4. 更新输入框占位文案与回复提示区域的展示
5. 优化回复内容的文字颜色与排版
2026-06-15 22:40:39 +08:00
小莫唐尼 9c028bdc1b style(comment-panel): 调整加载组件样式并添加加载文案
优化评论列表加载状态的视觉表现,增加黑色加载图标与加载中文案提示
2026-06-15 22:09:59 +08:00
小莫唐尼 1ad951913e refactor: 优化多篇页面细节与评论展示效果
1. 隐藏两个页面的测试调试模块
2. 将图库默认样式改为card模式
3. 优化评论项间距与图标、内容展示
4. 调整代码块圆角样式与文章详情页逻辑
5. 新增文章导航封面处理与加载逻辑优化
2026-06-15 22:02:59 +08:00
小莫唐尼 e191eb8358 style: 调整背景模糊值与布局样式,优化评论与图片功能
1.  调整全局背景模糊参数,统一并优化页面毛玻璃效果
2.  调整home页布局类名顺序,为post-card添加全屏宽度
3.  新增头像处理工具函数,补充随机图片源
4.  优化markdown内容容器的内边距样式
5.  重构文章详情页评论面板,实现完整评论回复功能
6.  优化文章详情页布局与样式细节
2026-06-15 21:29:14 +08:00
小莫唐尼 a86baa9641 feat(blog,comment): add post comment panel and optimize tips
1. 新增文章评论弹窗组件uh-post-comment-panel
2. 给文章详情页评论按钮绑定打开评论面板事件
3. 优化图片画廊和评论加载失败的提示文案与样式
4. 修复评论列表接口返回类型适配问题
2026-06-15 18:53:48 +08:00
小莫唐尼 c4fc9c930e style: 调整主题色为深灰并优化文章页样式
1.  将全局主题色替换为#1A1A1A
2.  重构markdown样式,优化间距与排版
3.  新增文章页返回顶部功能
4.  优化文章详情页的布局与交互细节
2026-06-15 18:26:56 +08:00
小莫唐尼 01c929c4b5 refactor(blog-post-detail): 重构文章详情页布局与交互栏
1.  将分类和标签整合到文章标题下方统一展示
2.  移除独立的标签区域,将互动操作栏改为底部浮动栏
3.  新增分类标签样式优化,调整整体视觉风格
4.  添加阴影和毛玻璃背景工具类
2026-06-15 17:14:48 +08:00
小莫唐尼 2cc78dd329 feat: 新增博客token配置,完善文章详情页与markdown样式
1.  新增IUniHaloConfig的token配置项并关联环境变量
2.  调整文章跳转路径到subpkg分包目录
3.  重构markdown配置使用主题色替换硬编码值
4.  升级代码块样式为深色主题并添加圆角
5.  新增完整的文章详情页面实现
6.  删除冗余的markdown样式scss文件
7.  添加项目claude开发指南文档
2026-06-15 16:36:23 +08:00
小莫唐尼 b56de87856 refactor(home&post-card): 调整点击事件绑定位置并优化空状态样式
1. 将作者卡片的点击事件从子组件移到父容器上,简化子组件依赖
2. 给空状态提示容器添加box-border类统一样式规范
3. 为文章卡片添加点击跳转详情页的逻辑
2026-06-15 13:32:56 +08:00
31 changed files with 2035 additions and 718 deletions
+118
View File
@@ -0,0 +1,118 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
uni-halo v3.0 is a multi-platform blog client built on the **Halo 2.x** headless CMS API. It targets WeChat mini-program (primary), H5, Alipay, and native apps. Built on the **unibest** scaffold with Vue 3 + TypeScript + Vite + UnoCSS.
## Commands
| Task | Command |
|------|---------|
| Install dependencies | `pnpm install` |
| Dev (H5) | `pnpm dev` or `pnpm dev:h5` |
| Dev (WeChat mini-program) | `pnpm dev:mp` or `pnpm dev:mp-weixin` |
| Dev (Alipay mini-program) | `pnpm dev:mp-alipay` |
| Dev (native app) | `pnpm dev:app` |
| Build (H5) | `pnpm build` or `pnpm build:h5` |
| Build (WeChat) | `pnpm build:mp` |
| Build (app) | `pnpm build:app` |
| Lint | `pnpm lint` |
| Lint + auto-fix | `pnpm lint:fix` |
| Type check | `pnpm type-check` |
| Run tests (watch) | `pnpm test` |
| Run tests (single) | `pnpm test:run` |
| Generate API client from OpenAPI | `pnpm openapi` |
| Upload to WeChat platform | `pnpm upload:mp` |
## Architecture
### Entry Flow
`src/main.ts` → creates Vue app → installs Pinia → route interceptor → request interceptor → i18n. On launch, `src/App.vue` fetches the uni-halo plugin configuration from the Halo server and stores it in the `uniHaloConfig` Pinia store.
### Routing (Convention-Based)
Pages in `src/pages/` are auto-registered by `@uni-helper/vite-plugin-uni-pages`. Use the `definePage()` macro at the top of page files to set metadata (navigation style, title, etc.). Sub-packages live in `src/subpkg-blog/` and `src/subpkg-demo/`.
Route guards in `src/router/interceptor.ts` intercept `navigateTo`, `reLaunch`, `redirectTo`, `switchTab`. Supports whitelist (default: login required) and blacklist strategies, configured in `src/router/config.ts`.
### HTTP Layer — Three Coexisting Strategies
1. **Custom `uni.request` wrapper** (`src/http/http.ts`) — GET/POST/PUT/DELETE helpers with transparent 401 token refresh (queues pending requests during refresh). Used by `src/api/login.ts`.
2. **Alova** (`src/http/alova.ts`) — Alova 3.3+ with `@alova/adapter-uniapp`, Halo-specific response handling. Used by `src/api/halo-base/*` and `src/api/halo-plugin/*`.
3. **vue-query** (`src/http/vue-query.ts`) — skeleton/option, not actively used.
Request interceptor (`src/http/interceptor.ts`) attaches `Authorization: Bearer <token>` to all requests.
### Authentication
Dual token mode support (single access token OR access+refresh pair), controlled by `VITE_AUTH_MODE` env var. Token store (`src/store/token.ts`) handles login (standard + WeChat mini-program), logout, and automatic refresh with computed expiry checks.
### API Layer
- `src/api/halo-base/` — Core Halo APIs: posts, categories, tags, comments, stats, trackers
- `src/api/halo-plugin/` — uni-halo plugin APIs: plugin config, moments, links, gallery, votes, douban, muyin, data stats
- `src/api/login.ts` — Auth APIs (login, register, WeChat login, token refresh)
- `src/api/types/` — TypeScript interfaces for API responses
- Types from `@halo-dev/api-client` (e.g., `ListedPostVo`, `PostVo`)
### State Management
Pinia with `pinia-plugin-persistedstate` using `uni.getStorageSync`/`uni.setStorageSync`. All stores use Composition API style (`defineStore` with setup function):
- `src/store/token.ts` — Authentication
- `src/store/user.ts` — User profile
- `src/store/config.ts` — uni-halo plugin configuration
### Custom Tabbar
Fully custom tabbar in `src/tabbar/` with its own Pinia store. Configurable strategy: no tabbar, native tabbar, or custom tabbar. Currently set to `CUSTOM_TABBAR` with 5 tabs.
## Coding Conventions
### Vue Components
- Use `<script setup lang="ts">` exclusively — script must be first, template second, style last
- Name components with `defineOptions({ name: '...' })`
- Define props with `interface` + `withDefaults(defineProps<IProps>(), {...})`
- Project-specific components prefixed with `uh-` (e.g., `uh-post-card`, `uh-image`)
- Global components in `src/components/`, page-local in `src/pages/xx/components/`
- UI library: wot-ui (`wd-*` components), auto-imported via easycom
### TypeScript
- Use `interface` for object types, `type` for union types
- Use `import type` for type-only imports
- Avoid `any`
### Styling
- Prefer UnoCSS atomic classes over custom CSS
- SCSS with `scoped` when custom styles are needed; follow BEM naming
- Use `rpx` units for cross-platform screen adaptation
- Safe-area utilities: `p-safe`, `pt-safe`, `pb-safe`
- UnoCSS shortcut `center` = `flex items-center justify-center`
- Theme color `primary` from wot-ui CSS variable `--wd-primary-color`
### Platform Conditional Compilation
Use uni-app directives for platform-specific code:
```vue
<!-- #ifdef H5 -->
<view>H5 only</view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<view>WeChat only</view>
<!-- #endif -->
```
Import boundaries must not be reordered by ESLint — `perfectionist/sort-imports` is disabled for this reason.
### Git
- Conventional commits enforced via commitlint (`@commitlint/config-conventional`)
- Write commit messages in Chinese
- Pre-commit runs lint-staged via Husky
### i18n
English and Simplified Chinese via vue-i18n. Locale files in `src/locale/` (en.json, zh-Hans.json).
## Path Aliases
- `@``./src`
- `@img``./src/static/images`
## Environment Configuration
Environment files in `env/` directory:
- `.env` — Base config (app title, port 9000, app IDs, auth mode)
- `.env.development` — Dev backend URL, console enabled
- `.env.test` — Test environment
- `.env.production` — Production (console stripped)
+2
View File
@@ -0,0 +1,2 @@
我们需要全局缓存一个点赞的列表,使用 metadata.name 做key,存储到store并且开启持久化存储,如果已经点过赞,直接显示已
经点赞的样式。同时我们的点赞接口是不会返回内容的,所以再捕获异常的时候不要提示异常信息。
+3 -2
View File
@@ -1,5 +1,6 @@
import { http } from '@/http/alova' import { http } from '@/http/alova'
import type { Comment, CommentV1alpha1PublicApiCreateComment1Request, CommentV1alpha1PublicApiCreateReply1Request, CommentV1alpha1PublicApiGetCommentRequest, CommentV1alpha1PublicApiListCommentRepliesRequest, CommentV1alpha1PublicApiListComments1Request, CommentVoList, CommentWithReplyVoList, Reply, ReplyVoList } from '@halo-dev/api-client' import type { Comment, CommentV1alpha1PublicApiCreateComment1Request, CommentV1alpha1PublicApiCreateReply1Request, CommentV1alpha1PublicApiGetCommentRequest, CommentV1alpha1PublicApiListCommentRepliesRequest, CommentV1alpha1PublicApiListComments1Request, CommentVoList, CommentWithReplyVo, Reply, ReplyVoList } from '@halo-dev/api-client'
import type { IHaloListResponseBase } from '../types/halo'
const EndpointBase = '/apis/api.halo.run/v1alpha1/comments' const EndpointBase = '/apis/api.halo.run/v1alpha1/comments'
@@ -35,7 +36,7 @@ export function createReply(data: CommentV1alpha1PublicApiCreateReply1Request) {
* @returns 获取评论列表响应参数 * @returns 获取评论列表响应参数
*/ */
export function listComments(params: CommentV1alpha1PublicApiListComments1Request) { export function listComments(params: CommentV1alpha1PublicApiListComments1Request) {
return http.Get<CommentWithReplyVoList>(`${EndpointBase}`, { return http.Get<IHaloListResponseBase<CommentWithReplyVo>>(`${EndpointBase}`, {
params, params,
meta: { meta: {
isHalo: true, isHalo: true,
+6
View File
@@ -1,9 +1,15 @@
import { http } from '@/http/alova' import { http } from '@/http/alova'
export interface IUniHaloConfig { export interface IUniHaloConfig {
// 应用信息配置
app: {
name: string
}
base: { base: {
// 博客域名 // 博客域名
domain: string domain: string
// 博客token
token: string
// 是否为个人资质的小程序 // 是否为个人资质的小程序
isIndividual: boolean isIndividual: boolean
} }
@@ -821,7 +821,7 @@ export default {
} }
} }
</script> </script>
<style>/deep/ .hl-code,/deep/ .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}/deep/ .hl-pre{padding:1em;margin:.5em 0;overflow:auto}/deep/ .hl-pre{background:#2d2d2d}/deep/ .hl-block-comment,/deep/ .hl-cdata,/deep/ .hl-comment,/deep/ .hl-doctype,/deep/ .hl-prolog{color:#999}/deep/ .hl-punctuation{color:#ccc}/deep/ .hl-attr-name,/deep/ .hl-deleted,/deep/ .hl-namespace,/deep/ .hl-tag{color:#e2777a}/deep/ .hl-function-name{color:#6196cc}/deep/ .hl-boolean,/deep/ .hl-function,/deep/ .hl-number{color:#f08d49}/deep/ .hl-class-name,/deep/ .hl-constant,/deep/ .hl-property,/deep/ .hl-symbol{color:#f8c555}/deep/ .hl-atrule,/deep/ .hl-builtin,/deep/ .hl-important,/deep/ .hl-keyword,/deep/ .hl-selector{color:#cc99cd}/deep/ .hl-attr-value,/deep/ .hl-char,/deep/ .hl-regex,/deep/ .hl-string,/deep/ .hl-variable{color:#7ec699}/deep/ .hl-entity,/deep/ .hl-operator,/deep/ .hl-url{color:#67cdcc}/deep/ .hl-bold,/deep/ .hl-important{font-weight:700}/deep/ .hl-italic{font-style:italic}/deep/ .hl-entity{cursor:help}/deep/ .hl-inserted{color:green}/deep/ .md-p { <style>/deep/ .hl-code,/deep/ .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}/deep/ .hl-pre{padding:1em;margin:.5em 0;overflow:auto}/deep/ .hl-pre{background:#1a1a1a;border-radius:8px}/deep/ .hl-block-comment,/deep/ .hl-cdata,/deep/ .hl-comment,/deep/ .hl-doctype,/deep/ .hl-prolog{color:#999}/deep/ .hl-punctuation{color:#ccc}/deep/ .hl-attr-name,/deep/ .hl-deleted,/deep/ .hl-namespace,/deep/ .hl-tag{color:#e2777a}/deep/ .hl-function-name{color:#6196cc}/deep/ .hl-boolean,/deep/ .hl-function,/deep/ .hl-number{color:#f08d49}/deep/ .hl-class-name,/deep/ .hl-constant,/deep/ .hl-property,/deep/ .hl-symbol{color:#f8c555}/deep/ .hl-atrule,/deep/ .hl-builtin,/deep/ .hl-important,/deep/ .hl-keyword,/deep/ .hl-selector{color:#cc99cd}/deep/ .hl-attr-value,/deep/ .hl-char,/deep/ .hl-regex,/deep/ .hl-string,/deep/ .hl-variable{color:#7ec699}/deep/ .hl-entity,/deep/ .hl-operator,/deep/ .hl-url{color:#67cdcc}/deep/ .hl-bold,/deep/ .hl-important{font-weight:700}/deep/ .hl-italic{font-style:italic}/deep/ .hl-entity{cursor:help}/deep/ .hl-inserted{color:green}/deep/ .md-p {
margin-block-start: 1em; margin-block-start: 1em;
margin-block-end: 1em; margin-block-end: 1em;
} }
@@ -1 +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> <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:#1a1a1a;border-radius:8px}.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,134 @@
<script setup lang="ts">
import { useGuestStore } from '@/store/guest'
import type { IGuestInfo } from '@/store/guest'
const emits = defineEmits<{
(e: 'close'): void
(e: 'saved'): void
}>()
const guestStore = useGuestStore()
const formData = reactive<IGuestInfo>({
displayName: guestStore.info.displayName || '',
email: guestStore.info.email || '',
website: guestStore.info.website || '',
})
const rules = reactive({
displayName: '',
email: '',
})
function validate(): boolean {
let valid = true
rules.displayName = ''
rules.email = ''
if (!formData.displayName.trim()) {
rules.displayName = '请输入昵称'
valid = false
}
if (!formData.email.trim()) {
rules.email = '请输入邮箱'
valid = false
}
else if (!/^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(formData.email)) {
rules.email = '邮箱格式不正确'
valid = false
}
return valid
}
function handleSubmit() {
if (!validate())
return
guestStore.save({
displayName: formData.displayName.trim(),
email: formData.email.trim(),
website: formData.website.trim(),
})
uni.showToast({ title: '保存成功', icon: 'none' })
emits('saved')
emits('close')
}
function handleClose() {
emits('close')
}
</script>
<template>
<view class="fixed inset-0 z-120 flex items-end">
<!-- 遮罩 -->
<view class="absolute inset-0 bg-black/40" @click="handleClose" />
<!-- 面板 -->
<wd-transition :show="true" :duration="300" name="slide-up" :lazy-render="false">
<view
class="relative box-border w-screen flex flex-col rounded-t-3xl bg-white pb-safe"
@click.stop
>
<!-- 拖拽指示条 -->
<view class="w-full center py-2" @click.stop="handleClose">
<view class="h-1 w-10 rounded-full bg-gray-300" />
</view>
<!-- 头部 -->
<view class="box-border flex items-center justify-between border-b border-gray-100 px-4 pb-3">
<text class="text-base text-gray-900 font-bold">游客信息</text>
<view class="h-8 w-8 center rounded-full bg-gray-50" @click="handleClose">
<wd-icon name="close" color="#6B7280" :size="18" />
</view>
</view>
<!-- 表单 -->
<view class="box-border flex flex-col gap-y-4 px-4 pt-4">
<!-- 昵称 -->
<view class="flex flex-col gap-y-1">
<input
v-model="formData.displayName"
class="rounded-xl bg-gray-50 px-4 py-2.5 text-sm"
placeholder="请输入昵称"
:maxlength="30"
>
<text v-if="rules.displayName" class="text-[11px] text-red-500">{{ rules.displayName }}</text>
</view>
<!-- 邮箱 -->
<view class="flex flex-col gap-y-1">
<input
v-model="formData.email"
class="rounded-xl bg-gray-50 px-4 py-2.5 text-sm"
placeholder="请输入邮箱"
type="text"
:maxlength="100"
>
<text v-if="rules.email" class="text-[11px] text-red-500">{{ rules.email }}</text>
</view>
<!-- 网站 -->
<view class="flex flex-col gap-y-1">
<input
v-model="formData.website"
class="rounded-xl bg-gray-50 px-4 py-2.5 text-sm"
placeholder="请输入网站(选填)"
type="text"
:maxlength="200"
>
</view>
<!-- 提交按钮 -->
<view
class="h-11 w-full center rounded-xl bg-gray-900 text-sm text-white font-medium"
@click="handleSubmit"
>
保存
</view>
<view class="h-2 w-full" />
</view>
</view>
</wd-transition>
</view>
</template>
@@ -0,0 +1,527 @@
<script setup lang="ts">
import { createComment, createReply, listCommentReplies, listComments } from '@/api/halo-base/comment'
import { avatar } from '@/utils/imageHelper'
import { timeFrom } from '@/utils/base/timeFrom'
import { useGuestStore } from '@/store/guest'
import { useUpvoteStore } from '@/store/upvote'
import type { CommentV1alpha1PublicApiListCommentRepliesRequest, CommentV1alpha1PublicApiListComments1Request, CommentWithReplyVo, ReplyVo } from '@halo-dev/api-client'
import type { IHaloListResponseBase } from '@/api/types/halo'
import UhGuestInfoPanel from './uh-guest-info-panel.vue'
interface IProps {
postName: string
commentSubjectVersion: string
commentCount?: number
}
const props = withDefaults(defineProps<IProps>(), {
commentCount: 0,
})
const emits = defineEmits<{
(e: 'close'): void
(e: 'commented'): void
}>()
const guestStore = useGuestStore()
const upvoteStore = useUpvoteStore()
// --- 评论列表 ---
const commentList = ref<CommentWithReplyVo[]>([])
const commentPage = ref(1)
const commentHasNext = ref(false)
const commentLoading = ref(false)
// --- 回复目标 ---
const replyTarget = ref<{
commentName: string
replyName?: string
displayName: string
rawContent: string
} | null>(null)
// --- 输入内容 ---
const inputText = ref('')
const submitLoading = ref(false)
// --- 展开回复的评论 ---
const expandedReplies = ref<Set<string>>(new Set())
const replyLoadingMap = ref<Record<string, boolean>>({})
const replyHasNextMap = ref<Record<string, boolean>>({})
const replyPageMap = ref<Record<string, number>>({})
// --- 游客信息面板 ---
const showGuestPanel = ref(false)
// --- 点赞(切换) ---
async function handleUpvote(name: string) {
const nowUpvoted = await upvoteStore.toggle(name, 'comments')
// 更新本地计数
for (const comment of commentList.value) {
if (comment.metadata.name === name) {
comment.stats.upvote = Math.max(0, (comment.stats.upvote ?? 0) + (nowUpvoted ? 1 : -1))
break
}
const reply = comment.replies?.items?.find(r => r.metadata.name === name)
if (reply) {
reply.stats.upvote = Math.max(0, (reply.stats.upvote ?? 0) + (nowUpvoted ? 1 : -1))
break
}
}
}
// --- 获取评论列表 ---
async function fetchComments(page = 1, append = false) {
commentLoading.value = true
try {
const res = await listComments({
group: 'content.halo.run',
name: props.postName,
kind: 'Post',
version: props.commentSubjectVersion,
page,
size: 20,
sort: ['metadata.creationTimestamp,desc'],
} as CommentV1alpha1PublicApiListComments1Request)
const data = res as unknown as IHaloListResponseBase<CommentWithReplyVo>
if (append) {
commentList.value = [...commentList.value, ...(data.items ?? [])]
}
else {
commentList.value = data.items ?? []
}
commentHasNext.value = data.hasNext
commentPage.value = page
}
catch (err) {
console.error('获取评论失败:', err)
}
finally {
commentLoading.value = false
}
}
// --- 加载更多评论 ---
function handleLoadMoreComments() {
if (commentLoading.value || !commentHasNext.value)
return
fetchComments(commentPage.value + 1, true)
}
// --- 获取回复列表 ---
async function fetchReplies(commentName: string, page = 1, append = false) {
replyLoadingMap.value[commentName] = true
try {
const res = await listCommentReplies({
name: commentName,
page,
size: 10,
} as CommentV1alpha1PublicApiListCommentRepliesRequest)
const data = res as unknown as IHaloListResponseBase<ReplyVo>
const comment = commentList.value.find(c => c.metadata.name === commentName)
if (comment) {
if (append) {
comment.replies = {
...comment.replies,
items: [...(comment.replies?.items ?? []), ...(data.items ?? [])],
hasNext: data.hasNext,
} as any
}
else {
comment.replies = {
...data,
items: data.items ?? [],
} as any
}
}
replyHasNextMap.value[commentName] = data.hasNext
replyPageMap.value[commentName] = page
}
catch (err) {
console.error('获取回复失败:', err)
uni.showToast({ title: '获取回复失败', icon: 'none' })
}
finally {
replyLoadingMap.value[commentName] = false
}
}
// --- 切换展开回复 ---
function handleToggleReplies(comment: CommentWithReplyVo) {
const name = comment.metadata.name
if (expandedReplies.value.has(name)) {
expandedReplies.value.delete(name)
}
else {
expandedReplies.value.add(name)
if (!comment.replies?.items?.length) {
fetchReplies(name)
}
}
}
// --- 加载更多回复 ---
function handleLoadMoreReplies(commentName: string) {
if (replyLoadingMap.value[commentName] || !replyHasNextMap.value[commentName])
return
fetchReplies(commentName, (replyPageMap.value[commentName] ?? 1) + 1, true)
}
// --- 设置回复目标 ---
function handleReply(commentName: string, replyName: string | undefined, displayName: string, rawContent: string) {
replyTarget.value = { commentName, replyName, displayName, rawContent }
}
// --- 取消回复 ---
function handleCancelReply() {
replyTarget.value = null
}
// --- 提交评论/回复 ---
function handleSubmit() {
const text = inputText.value.trim()
if (!text || submitLoading.value)
return
// 检查游客信息,未设置直接弹出面板
if (!guestStore.isComplete) {
showGuestPanel.value = true
return
}
doSubmit(text)
}
async function doSubmit(text: string) {
submitLoading.value = true
try {
const owner = {
displayName: guestStore.info.displayName,
email: guestStore.info.email,
website: guestStore.info.website || undefined,
}
if (replyTarget.value) {
await createReply({
name: replyTarget.value.commentName,
replyRequest: {
content: text,
raw: text,
quoteReply: replyTarget.value.replyName || undefined,
allowNotification: true,
owner,
},
} as any)
}
else {
await createComment({
commentRequest: {
content: text,
raw: text,
subjectRef: {
group: 'content.halo.run',
kind: 'Post',
name: props.postName,
version: props.commentSubjectVersion,
},
allowNotification: true,
hidden: false,
owner,
},
} as any)
}
inputText.value = ''
replyTarget.value = null
uni.showToast({ title: '评论成功', icon: 'none' })
await fetchComments(1)
emits('commented')
}
catch (err) {
console.error('评论失败:', err)
uni.showToast({ title: '评论失败', icon: 'none' })
}
finally {
submitLoading.value = false
}
}
// --- 游客信息保存后自动提交 ---
function handleGuestSaved() {
showGuestPanel.value = false
// 保存后如果有输入内容,自动提交
const text = inputText.value.trim()
if (text) {
doSubmit(text)
}
}
// --- 查找被引用的回复 ---
function findQuotedReply(comment: CommentWithReplyVo, quoteReplyName: string): ReplyVo | undefined {
return comment.replies?.items?.find(r => r.metadata.name === quoteReplyName)
}
// --- 关闭面板 ---
function handleClose() {
emits('close')
}
defineExpose({
refresh: () => fetchComments(),
})
onMounted(() => {
fetchComments()
})
</script>
<template>
<view class="uh-backdrop-blur fixed inset-0 z-110 flex items-end bg-black/20" @click="handleClose">
<wd-transition :show="true" :duration="300" name="slide-up" :lazy-render="false">
<!-- 面板主体 -->
<view
class="box-border w-screen flex flex-col rounded-t-3xl bg-white"
@click.stop
>
<!-- 拖拽指示条 -->
<view class="w-full center py-2" @click.stop="handleClose">
<view class="h-1 w-10 rounded-full bg-gray-300" />
</view>
<!-- 头部 -->
<view class="box-border flex items-center justify-between border-b border-gray-100 px-4 pb-3">
<text class="text-base text-gray-900 font-bold">
说点什么 <text v-if="commentCount > 0" class="text-sm text-gray-600 font-normal">{{ commentCount }}</text>
</text>
<view class="h-8 w-8 center rounded-full bg-gray-50" @click="handleClose">
<wd-icon name="close" color="#6B7280" :size="18" />
</view>
</view>
<!-- 评论列表 -->
<scroll-view
class="h-[60vh] overflow-hidden"
scroll-y
@scrolltolower="handleLoadMoreComments"
>
<view class="box-border h-full px-4 pt-3">
<!-- 加载中 -->
<view v-if="commentLoading && commentList.length === 0" class="box-border h-full center">
<wd-loading :size="32" color="#1a1a1a">
加载中...
</wd-loading>
</view>
<!-- 空状态 -->
<view v-else-if="!commentLoading && commentList.length === 0" class="box-border h-full center flex-col gap-y-2">
<wd-icon name="chat" color="#D1D5DB" :size="40" />
<text class="text-sm text-gray-400">快来抢沙发吧~</text>
</view>
<!-- 评论列表 -->
<view v-else class="box-border h-full w-full flex flex-col">
<view
v-for="comment in commentList"
:key="comment.metadata.name"
class="border-b border-gray-50 py-3"
>
<view class="flex gap-x-3">
<!-- 头像 -->
<image
class="uh-shadow-md box-border h-9 w-9 shrink-0 border-2 border-white rounded-xl border-solid bg-gray-100"
:src="avatar(comment.owner.avatar)"
mode="aspectFill"
/>
<!-- 内容区 -->
<view class="flex flex-1 flex-col gap-y-2 overflow-hidden">
<view class="flex items-center justify-between">
<text class="text-xs text-gray-500 font-medium">{{ comment.owner.displayName || '匿名用户' }}</text>
<text class="text-[11px] text-gray-400">{{ timeFrom(comment.spec.creationTime || comment.metadata.creationTimestamp) }}</text>
</view>
<text class="text-sm text-gray-900 leading-5">
<rich-text :nodes="comment.spec.raw" />
</text>
<!-- 操作栏 -->
<view class="flex items-center gap-x-4">
<!-- 点赞 -->
<view
class="flex items-center gap-x-0.5"
@click="handleUpvote(comment.metadata.name)"
>
<wd-icon
name="thumb-up"
:color="upvoteStore.isUpvoted(comment.metadata.name) ? '#EF4444' : '#9CA3AF'"
:size="14"
/>
<text
class="text-[11px]"
:class="upvoteStore.isUpvoted(comment.metadata.name) ? 'text-red-500' : 'text-gray-400'"
>
{{ comment.stats.upvote || '' }}
</text>
</view>
<!-- 回复 -->
<view
class="flex items-center gap-x-1"
@click="handleReply(comment.metadata.name, undefined, comment.owner.displayName || '匿名用户', comment.spec.raw)"
>
<wd-icon name="message" color="#9CA3AF" :size="14" />
<text class="text-[11px] text-gray-400">回复</text>
</view>
<!-- 展开回复 -->
<view
v-if="(comment.status?.replyCount ?? 0) > 0"
class="flex items-center gap-x-1"
@click="handleToggleReplies(comment)"
>
<wd-icon
:name="expandedReplies.has(comment.metadata.name) ? 'up' : 'down'"
color="#9CA3AF"
:size="14"
/>
<text class="text-[11px] text-gray-400">
{{ expandedReplies.has(comment.metadata.name) ? '收起' : `${comment.status?.replyCount}条回复` }}
</text>
</view>
</view>
<!-- 回复列表 -->
<view v-if="expandedReplies.has(comment.metadata.name)" class="mt-2 flex flex-col gap-y-2.5 rounded-xl bg-gray-50 p-3">
<view
v-for="reply in (comment.replies?.items ?? [])"
:key="reply.metadata.name"
class="flex gap-x-2.5"
>
<image
class="h-7 w-7 shrink-0 border-2 border-white rounded-lg border-solid bg-gray-200"
:src="avatar(reply.owner.avatar)"
mode="aspectFill"
/>
<view class="flex flex-1 flex-col gap-y-1.5 overflow-hidden">
<view class="flex items-center justify-between">
<text class="text-[11px] text-gray-500 font-medium">{{ reply.owner.displayName || '匿名用户' }}</text>
<text class="text-[10px] text-gray-400">{{ timeFrom(reply.spec.creationTime || reply.metadata.creationTimestamp) }}</text>
</view>
<view v-if="reply.spec.quoteReply" class="box-border rounded-lg bg-gray-100 px-2.5 py-1.5">
<view v-if="findQuotedReply(comment, reply.spec.quoteReply)" class="w-full flex items-center gap-x-1 text-[11px] text-gray-400">
<text class="shrink-0">回复 {{ findQuotedReply(comment, reply.spec.quoteReply)?.owner.displayName || '匿名用户' }}</text> <rich-text class="line-clamp-1" :nodes="findQuotedReply(comment, reply.spec.quoteReply)?.spec.raw" />
</view>
<view v-else class="line-clamp-1 w-full flex items-center gap-x-1 text-[11px] text-gray-400">
<text class="shrink-0">回复 {{ comment.owner.displayName || '匿名用户' }}</text> <rich-text class="line-clamp-1" :nodes="comment.spec.raw" />
</view>
</view>
<text class="text-xs text-gray-900 leading-4.5">
<rich-text :nodes="reply.spec.raw" />
</text>
<!-- 回复操作栏 -->
<view class="flex items-center gap-x-3">
<!-- 点赞 -->
<view
class="flex items-center gap-x-0.5"
@click="handleUpvote(reply.metadata.name)"
>
<wd-icon
name="thumb-up"
:color="upvoteStore.isUpvoted(reply.metadata.name) ? '#EF4444' : '#9CA3AF'"
:size="12"
/>
<text
class="text-[10px]"
:class="upvoteStore.isUpvoted(reply.metadata.name) ? 'text-red-500' : 'text-gray-400'"
>
{{ reply.stats.upvote || '' }}
</text>
</view>
<!-- 回复 -->
<view
class="flex items-center gap-x-1"
@click="handleReply(comment.metadata.name, reply.metadata.name, reply.owner.displayName || '匿名用户', reply.spec.raw)"
>
<wd-icon name="message" color="#9CA3AF" :size="12" />
<text class="text-[10px] text-gray-400">回复</text>
</view>
</view>
</view>
</view>
<!-- 加载更多回复 -->
<view
v-if="replyHasNextMap[comment.metadata.name]"
class="center py-1"
@click="handleLoadMoreReplies(comment.metadata.name)"
>
<text class="text-xs text-gray-400">
{{ replyLoadingMap[comment.metadata.name] ? '加载中...' : '查看更多回复' }}
</text>
</view>
<view v-if="replyLoadingMap[comment.metadata.name] && !(comment.replies?.items?.length)" class="center py-2">
<wd-loading :size="20" color="#1a1a1a" direction="horizontal">
加载中
</wd-loading>
</view>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="commentHasNext" class="center py-3" @click="handleLoadMoreComments">
<text class="text-xs text-gray-400">{{ commentLoading ? '加载中...' : '加载更多评论' }}</text>
</view>
<view class="h-4 w-full" />
</view>
</view>
</scroll-view>
<!-- 底部输入区 -->
<view class="box-border border-t border-gray-100 px-4 pt-2">
<!-- 回复提示 -->
<view v-if="replyTarget" class="mb-2 flex items-center gap-x-2 rounded-lg bg-gray-50 px-3 py-1.5">
<view class="flex flex-1 flex-col overflow-hidden">
<text class="text-[11px] text-gray-400 font-medium">回复 {{ replyTarget.displayName }}</text>
<view class="line-clamp-1 text-[11px] text-gray-500">
<rich-text :nodes="replyTarget.rawContent" />
</view>
</view>
<view class="shrink-0" @click="handleCancelReply">
<wd-icon name="close" color="#9CA3AF" :size="14" />
</view>
</view>
<view class="flex items-center gap-x-3 pb-2">
<input
v-model="inputText"
class="flex-1 rounded-xl bg-gray-50 px-4 py-2.5 text-sm active:bg-gray-100 focus:bg-gray-100"
:placeholder="replyTarget ? `回复 ${replyTarget.displayName}...` : '说点什么吧...'"
confirm-type="send"
:adjust-position="true"
@confirm="handleSubmit"
>
<view
class="h-9 w-9 center shrink-0 rounded-xl transition-colors"
:class="inputText.trim() ? 'bg-gray-900' : 'bg-gray-200'"
@click="handleSubmit"
>
<wd-icon name="arrow-up" :color="inputText.trim() ? '#FFFFFF' : '#9CA3AF'" :size="18" />
</view>
</view>
</view>
</view>
</wd-transition>
</view>
<!-- 游客信息面板 -->
<uh-guest-info-panel
v-if="showGuestPanel"
@close="showGuestPanel = false"
@saved="handleGuestSaved"
/>
</template>
@@ -90,8 +90,8 @@ function handlePreview(item: IPhoto) {
</view> </view>
<view v-if="loading || error || finished"> <view v-if="loading || error || finished">
<wd-loading v-if="loading" text="加载中..." direction="horizontal" color="#1A1A1A" :size="16" /> <wd-loading v-if="loading" text="加载中..." direction="horizontal" color="#1A1A1A" :size="16" />
<view v-else-if="error" class="w-full rounded-lg bg-gray-900 py-1.5 text-center text-xs text-white"> <view v-else-if="error" class="w-full rounded-lg bg-gray-900 px-2 py-1.5 text-center text-xs text-white">
<wd-icon name="refresh" :size="14" /> 加载失败点击刷新试 <wd-icon name="refresh" :size="14" /> 加载失败点击
</view> </view>
<view v-else-if="finished" class="text-xs text-gray-900"> <view v-else-if="finished" class="text-xs text-gray-900">
数据已全部加载 数据已全部加载
+15 -7
View File
@@ -93,44 +93,52 @@ const textFooterClass = computed(() => {
} }
return 'gap-x-2' return 'gap-x-2'
}) })
function handleToPostDetail() {
uni.navigateTo({
url: `/subpkg-blog/post-detail/post-detail?metadataName=${props.post.metadata.name}`,
})
}
</script> </script>
<template> <template>
<view v-if="post" class="uh-shadow-xs box-border w-full flex justify-center overflow-hidden rounded-2xl bg-white p-4" :class="wrapClass"> <view v-if="post" class="uh-shadow-xs box-border w-full flex justify-center overflow-hidden rounded-2xl bg-white p-4" :class="wrapClass">
<view class="shrink-0 overflow-hidden rounded-lg" :class="coverClass"> <view class="shrink-0 overflow-hidden rounded-lg" :class="coverClass" @click="handleToPostDetail">
<image :src="completeUrl(post.spec.cover)" mode="aspectFill" class="h-full w-full" /> <image :src="completeUrl(post.spec.cover)" mode="aspectFill" class="h-full w-full" />
<view v-if="props.type === 'rightCover'" class="absolute bottom-0 left-0 top-0 z-10 h-full w-full from-white/0 via-white/10 to-white bg-gradient-to-l" /> <view v-if="props.type === 'rightCover'" class="absolute bottom-0 left-0 top-0 z-10 h-full w-full from-white/0 via-white/10 to-white bg-gradient-to-l" />
</view> </view>
<view class="box-border flex-1" :class="textWrapClass"> <view class="box-border flex-1" :class="textWrapClass">
<view class="h-full flex flex-1 flex-col justify-between" :class="textContainerClass"> <view class="h-full flex flex-1 flex-col justify-between" :class="textContainerClass">
<view class="line-clamp-1 text-sm text-gray-900 font-bold"> <view class="line-clamp-1 text-sm text-gray-900 font-bold" @click="handleToPostDetail">
{{ post.spec.title }} {{ post.spec.title }}
</view> </view>
<view class="line-clamp-2 text-xs text-gray-500"> <view class="line-clamp-2 text-xs text-gray-500" @click="handleToPostDetail">
{{ post.status.excerpt }} {{ post.status.excerpt }}
</view> </view>
<view class="w-full flex items-center" :class="textFooterClass"> <view class="w-full flex items-center" :class="textFooterClass">
<view class="line-clamp-1 text-[11px] text-gray-600"> <view class="line-clamp-1 text-xs text-gray-600">
{{ timeFrom(post.spec.publishTime) }} {{ timeFrom(post.spec.publishTime) }}
</view> </view>
<template v-if="false">
<view class="shrink-0 text-xs text-gray-600"> <view class="shrink-0 text-xs text-gray-600">
· ·
</view> </view>
<view class="line-clamp-1 text-[11px] text-gray-600"> <view class="line-clamp-1 text-xs text-gray-600">
{{ post.categories.map(c => c.spec.displayName).join('·') }} {{ post.categories.map(c => c.spec.displayName).join('·') }}
</view> </view>
</template>
<template v-if="props.type === 'topCover'"> <template v-if="props.type === 'topCover'">
<view class="shrink-0 text-xs text-gray-600"> <view class="shrink-0 text-xs text-gray-600">
· ·
</view> </view>
<view class="text-[11px] text-gray-600"> <view class="text-xs text-gray-600">
点赞 {{ post.stats.upvote }} 点赞 {{ post.stats.upvote }}
</view> </view>
</template> </template>
<view class="shrink-0 text-xs text-gray-600"> <view class="shrink-0 text-xs text-gray-600">
· ·
</view> </view>
<view class="line-clamp-1 text-[11px] text-gray-600"> <view class="line-clamp-1 text-xs text-gray-600">
浏览 {{ post.stats.visit }} 浏览 {{ post.stats.visit }}
</view> </view>
</view> </view>
+26 -15
View File
@@ -1,5 +1,7 @@
import { useAppConfig } from '../useAppConfig' import { useAppConfig } from '../useAppConfig'
const primaryColor = '#1a1a1a'
export const markdownConfig = { export const markdownConfig = {
domain: useAppConfig().appBaseUrl.value, domain: useAppConfig().appBaseUrl.value,
tagStyle: { tagStyle: {
@@ -9,8 +11,8 @@ border-collapse:collapse;
margin-bottom: 18px; margin-bottom: 18px;
overflow: hidden; overflow: hidden;
font-size: 13px; font-size: 13px;
color: var(--routine); color: ${primaryColor};
background: #f2f6fc; background: rgba(247, 247, 247, 1);
border: 1px solid #dcdcdc; border: 1px solid #dcdcdc;
border-radius: 4px; border-radius: 4px;
`, `,
@@ -26,68 +28,77 @@ border-bottom: 1px solid var(--classE);
`, `,
blockquote: ` blockquote: `
padding: 8px 15px; padding: 8px 15px;
padding-bottom: 1px;
color: #606266; color: #606266;
background: #f2f6fc; background: rgba(247, 247, 247, 1);
border-left: 5px solid #50bfff; border-left: 5px solid ${primaryColor};
border-radius: 4px; border-radius: 4px;
line-height: 26px; line-height: 26px;
margin-bottom: 18px; margin-bottom: 18px;
margin-top: 4px;
`, `,
ul: 'padding-left: 15px;line-height: 1.85;', ul: 'padding-left: 24px;line-height: 1.85;',
ol: 'padding-left: 15px;line-height: 1.85;', ol: 'padding-left: 24px;line-height: 1.85;',
li: 'margin-bottom: 12px;line-height: 1.85;', li: 'margin-bottom: 12px;line-height: 1.85;',
h1: ` h1: `
margin: 30px 0 20px; margin: 30px 0 20px;
color: var(--main); color: ${primaryColor};
line-height: 24px; line-height: 24px;
position: relative; position: relative;
font-size:1.2em; font-size:1.2em;
font-weight: bold;
`, `,
h2: ` h2: `
color: var(--main); color: ${primaryColor};
line-height: 24px; line-height: 24px;
position: relative; position: relative;
margin: 22px 0 16px; margin: 22px 0 16px;
font-size: 1.16em; font-size: 1.16em;
font-weight: bold;
`, `,
h3: ` h3: `
color: var(--main); color: ${primaryColor};
line-height: 24px; line-height: 24px;
position: relative; position: relative;
margin: 26px 0 18px; margin: 26px 0 18px;
font-size: 1.14em; font-size: 1.14em;
font-weight: bold;
`, `,
h4: ` h4: `
color: var(--main); color: ${primaryColor};
line-height: 24px; line-height: 24px;
margin-bottom: 18px; margin-bottom: 18px;
position: relative; position: relative;
font-size: 1.12em; font-size: 1.12em;
font-weight: bold;
`, `,
h5: ` h5: `
color: var(--main); color: ${primaryColor};
line-height: 24px; line-height: 24px;
margin-bottom: 14px; margin-bottom: 14px;
position: relative; position: relative;
font-size: 1.1em; font-size: 1.1em;
font-weight: bold;
`, `,
h6: ` h6: `
color: #303133; color: ${primaryColor};
line-height: 24px; line-height: 24px;
margin-bottom: 14px; margin-bottom: 14px;
position: relative; position: relative;
font-size: 14px; font-size: 14px;
font-weight: bold;
`, `,
p: ` p: `
line-height: 1.65; line-height: 1.65;
margin-bottom: 14px; margin-bottom: 12px;
font-size: 14px; font-size: 14px;
`, `,
code: ` `, code: ` `,
strong: 'font-weight: 700;color: rgb(248, 57, 41);', strong: `font-weight: 700;color: ${primaryColor};`,
video: 'width: 100%', 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;', // containStyle: 'font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 14px;color: #606266;word-spacing: 0.8px;letter-spacing: 0.8px; background-color:transparent;',
containStyle: 'padding:0 12px;word-spacing: 0.8px;letter-spacing: 0.8px; background-color:transparent;',
loadingGif: '', loadingGif: '',
emptyGif: '', emptyGif: '',
-155
View File
@@ -1,155 +0,0 @@
:root {
--main: #303133;
--theme: #fb6c28;
--code-background: #e8f3ff;
--radius-inner: 4px;
--classA: #dcdfe6;
--classB: #e4e7ed;
--classC: #ebeef5;
--classD: #f2f6fc;
--classE: #dcdcdc;
--classF: #333;
--classG: #dcdcdc;
--classH: #e9f2ff;
--classI: #5a3713;
--classJ: #f9e5fb;
--classK: #e4e7ed;
--classL: #666;
--classM: #2d2e37;
--quote: #50bfff;
--code: #409eff;
}
.uh-markdown {
::v-deep {
h1::before,
h2::before,
h3::before,
h4::before,
h5::before,
h6::before {
position: relative;
display: inline-block;
vertical-align: middle;
content: '';
margin-right: 6px;
background-position: center;
}
h1::before {
position: relative;
display: inline-block;
vertical-align: middle;
content: '';
top: -4px;
margin-right: 12px;
font-size: 24px;
color: var(--theme);
}
h2::before {
top: -2px;
left: 0;
width: 20px;
height: 20px;
background-size: auto 100%;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAVJJREFUWEftl7FKw1AUhv9rbmf1PRw6utkHEOoi6WPoE1ifQJ8gySKCOtRFRBKsDuIiDdZAURBFF50M2sFIzpGogVK03JA2cbh3S/i55zvfvYETgRyLj5erIKOO949FyKm5eHvvHEBV2jyruq1QDQ7n2DNXALEx+D7e2vl6lBYp76scHCzErtmCEPVhqEIAfus8BZk4wM+Zd/46tskDeI0mgLUyAdoAFsoDcM0XCDFdHoDX4FGfbRF3QANoA9qANqANaAPawD82EEWId1sAOJQWz6iO+5nGch41kDw9I3bbYOaTis21wgG4G4AuAzCwXrEoGV6V1ngMJPoPjoB+PzSIa8KBr1QdwFgA6KID7t1AgFYNC5uqxZNcbgB+eASdngHM+9LmpSzF8wFEEagbgHvXoQA3s3aegmYz8P1f4NNVME+39z5e3w4lkSMc3GXtPM1/AjYDFjDGddN5AAAAAElFTkSuQmCC);
}
h3::before {
top: -3px;
left: 0;
width: 20px;
height: 20px;
background-size: auto 100%;
background-repeat: none;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAH1JREFUWEft1qENgDAQheH/JANg60DAJIgmrMFCzEEYgTFgDyRFIClp0yBf9evl8pl3RsYbp7BjNLHoMptljPiMZH3WAhKQgAQkIIGXQAXUT79cniHaNMa5draliqqojIID86nRHEtvbSqlBSQgAQlIQAISkECRAA746SC5Ad6XpiGnnOGPAAAAAElFTkSuQmCC);
}
h4::before {
top: -2px;
width: 22px;
height: 22px;
color: var(--theme);
background-size: auto 100%;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAndJREFUWEftVkty2kAQfT1SBLtwg6AqwzbxCQIniG8QvAmwCp8kRqvgFa5KwHjlkI3NCeIbhJwgXptUgW9AdiDQdGoITqUsjcZyuYqNZ6WSXs88vel+3YQtL9ry+Xgk8KhApALNAe8x5PPbCUrgabdsD2/e+1edEpN4FsIRps7OwT9cXKKHCLw75QIL+V0XRFIUP1dptBx3ChKkxQlw8UnOG5mqLESgdsYZy5dTAE8jg5lH3Ypd5Mlxxl8ttDgGRulcq5iYgApofgn6ILzVBQsSu5/e0OXiqnMOotdatVjsOvkPl4muQIFrp5y1hJzEBA67ZavEk07WX5EexzxM5b1SYgJrFQbBOQDt3wVSuP0qTRfjowsAr3SHODa75HrqqiKX1gdMycjAYa9stU3JCPBhKue1ExNQAY3BakSgl1HBDMykI9z+Ps0WV0eXIITKdhM3c+yUS259FrVPrBM2TlclEnSmY8+S93tV+/yvH+hxxLzv5D11paFltOLmIFD3FzIbtRMzpr2K5arn+bgzJZAWl8631rjby0zAUJI3xnTfkjQTiFEAwHW3bGWNCoCv0zlvjUukgOoJgPymzQGg3itbfVMOMKiezh30ExOIqwIAvwNHZFUVzMcdbbUonGOnsomr4P1XfiFZ/tS6GOOkW7FqJh8gwomz06ol9oE7O6GhH9zLCbfeCxqDoE3AR61sm5nA/3XUZ47pmneYCSLnAeHLCQGZaAvmH72yXdjMA6oTanHpnFfQ5tDmQ+KJ6MZ+jckXY7//k4pWYB7sQVCUccxU3a9teHKcWS7ne0wI4Rhipqv7REZkku8hvhut+CEOidvjkcDWFfgD9RMzMKE7f80AAAAASUVORK5CYII=);
}
h5::before {
top: -1px;
left: 0;
width: 18px;
height: 18px;
background-size: 100% 100%;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAC8klEQVRYR+3WP2gTURwH8O/vKnVRRHKXP52cBO3g4p/BqYNIhy4muajUQRBFKjQV1En6ZxJBcmlRCoJDRe3FDiJVwamLS3FwqbgIgss1l2ZQF5XeT3I1Z3NJ7u5dLlAwN9699/t97vfe7/EIO/yhHe5DD9jpCv3fFVTu856+Xzi62Y/35hj9CFPNrlUwWeRJAJfBGADwBcBNI0/PRZFdAdo4xlQLjCqKjBzogat7hZCRAgPghJGBgbFnxglAOvS7b/fLb+q+qnv5BHBCyEBAWTdnANy2IxOtWSC1qsY+1jOFwAVG+gIVvXyHQbcaKuZCxmf5iMQogXFQtEsBeO5JT6BcMu+Bcb1lUhcyqfExACUAB6JEtgUqi+U5JrrmmcyFHCjwSYtQO+tSUSFbAmXdnAdwJVASdyWLPAS2kbFA8xsHNS13EzCmlx8R6KJQcBcypfFpho3cKxRna3ADsgEo6+ZjAKMhgjZ1d2KWR2gTSyD0h4jnIB2gXDIXwciFCPZvSnMl0wwshYrJGDImaMUGKovlLBPVOrDjh8APzFx8zDkjNa7FzYoGZsKT9XEarQOHmei1aJA246cqOWW6/i2l8VMGzoWIPW/k6eq2Ja6UwCz8pw2JGZ8sS8pUz8fWau/jGp+SgLchcGBgZD1Py41NUuoI+ZloV8ZU93+ogZKzfBiWfXAPCgMJU8Y42avQdMzI4ZBfmSizocqrUeJaAmsvBZGGBCtTziXeRY1rCxRAViEhXckqK93AeQJ9kYTvlmVlqmcTdhNEtefc+9X3utVmuX+CkK6oyqtu4nwrWP8bF5IZdGYjJ79wDuMCz4D+XmhFWnZbt7ab5ltBB6mbkyAaBPFCJassuwMmNb4L4EZgXwBc4AoGTZrUeA6A9x1yK6tzzvnFDlxBv0D176kCP2TCpbbjBXCRV9DZk0VeAONCE1IQ1zWg3dlF1sFQHWQIXFeBNrLAw5BwHBZWjQl6E3SbbB8X+R4Mg/Ca0wN2WtFeBTut4B84mFI4VpekyAAAAABJRU5ErkJggg==);
}
h6::before {
top: -1px;
left: 0;
width: 16px;
height: 16px;
background-size: auto 100%;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAEI0lEQVRYR+3Xb2wTZRwH8G/vL22RPy5GW8fK6rJpGeFFY9RO3TRRE1HfmEAammEyjZmOSEg1RrPZaTD6xjhU/Ndlf0CZgwmD+qcgZBRIETeqY0Vcnc7pGonhRY2l3m2t5upqjq693l2vcy+8N81zz/NcPvf75fd7rjos8ku3yH34HyjK0PqlDLPzD56vMNL0VHxmpgXAoUIZXKgI3sMSpK+cNTAbylaj/9IkprnLM3+mkg8XQi4EsJ4lSL+ZNbAfVd+JG/XLcSERg3M8IAtZauBtDEkeNdN6fX9NfRqXueQiSwm00wQRMDMGw94snBJkqYBrSYIIXs/ojftqGq6IXHZRFIpkKYC2JSw9SJE663W0nthlqcMawwrJYpVCag1M48pNy1YNvLWRffGNIYSCv6Cnog7rjCtVIbUEXoG7qeqaNMj51F58dWoKPZY62JeWyUJG+ctcIplcIizWCpgTl9EIyODJn9BtceDWq/6B57tO/H4R688fFaZ7AWzWAiiJEyMDx39Ed6UDdyy7Ni/w1ekxbP95VJi/F8CRYoGycGLksaEJdFU6cPdy0zxkR/RbtE6FhPvHATQUm2JFODHSfyyCLmsd7lth/hf53q/jcE8OC+NTAG7PTKiNoCqcGPnpF+PotDrwwNXl+OC3H9A8cVqYPgPgFnFo1QCLwomRg4cvoPMGBzZHTgq3zwKwZ+ddKVATXAZhf+gdnPvuojAUqmJdrspRAtQU93pXEM+8clgwhQHU5itruUBNcTt6TsP9sl8weQC0S/VFOUBNcW/2folt2z+XhZPTZh6kSKJ79aqVRuFszRxfUm8sNbdz9xlsfekz2bhCQBvDMMM8z+ubN92Mjrb71brS+97dM4wtnk8U4SSBBEGErVarzeVywePxoHVLA1pb6lUh3+8bwZMv+BTjpIAVACa9Xq+uqakJ7e3tqpGd/WfR3Jr+81awIJS0mY0A+sLhMGw2W3qfGmTXvhAef/6gapxUBDuqqqoejUQiBvFbKUH2DHyNx54bLAqXF0jT9HBjY6Pd6/XOi7oc5K7936Dp2QNF4/IBrTqdLuLxeIi2tracRSGF/PDgKB55er8muHzATQB2m81m+P1+1NbmPoVyIfsOnUOj+2PNcPmAO1iWbeE4TmexWODz+WQhayrL4No2oCkuJ5Bl2VGO49ZmcisXObdeVSuRaq7ZZ3G10KBTqRQl3pQPGY1GEQgE4HQ6heVDAO5S1cklNmUDXQRB9KZSqXkfESaTCW63G7FYDKFQKDEyMoJoNKqfe/bbAJ7QGpcrxa8xDLOV5/k0kGXZv2ZnZ5FMJtNjmqYvURR1IpFInAcwAeD7ud/pUuDmAY1G41g8Hl9DkmScoqggx3FHAIyJMMlSQfI9V5zKagDC93dsoRFKimQx2dIWOV/U/yn6bx0WyDj8vgLOAAAAAElFTkSuQmCC);
}
blockquote > p {
margin-bottom: 0 !important;
margin-top: 0 !important;
font-size: 0.9em !important;
}
code[class='md-code'],
code:not([class]) {
display: inline-block;
font-size: 13px;
color: #409eff;
margin: 2px 5px;
padding: 0 8px;
white-space: normal;
text-indent: 0;
-webkit-user-select: auto;
-moz-user-select: auto;
-ms-user-select: auto;
user-select: auto;
vertical-align: baseline;
word-break: break-word;
background: #e8f3ff;
border-radius: 4px;
}
code[class*='language-'] {
display: block;
overflow-x: auto;
// border-radius: 0 0 8px 8px;
white-space: pre-wrap;
word-break: break-all;
user-select: auto;
padding: 12px 12px 14px 18px;
margin-bottom: 16px;
background: #282c34;
color: #abb2bf;
border-radius: 4px;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
font-family: 'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
line-height: 1.5;
-moz-tab-size: 2;
-o-tab-size: 2;
tab-size: 2;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
table {
td {
padding: 8px;
border-right: 1px solid var(--classE);
border-bottom: 1px solid var(--classE);
}
thead th {
font-weight: 500;
background: var(--classC);
}
tbody tr {
transition: background 0.35s;
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import { storeToRefs } from 'pinia'
import { useUniHaloConfigStore } from '@/store/config'
export interface IShareAppMessageOption {
title?: string
path: string
query?: string
imageUrl?: string
onlyAppNameTitle?: boolean
}
/**
* 应用分享
*/
export function useAppShare() {
const { config } = storeToRefs(useUniHaloConfigStore())
const setShareOptions = (options: IShareAppMessageOption) => {
const { onlyAppNameTitle = false } = options
if (!options.path) {
return
}
const title = onlyAppNameTitle ? config.value.app.name : options.title
onShareAppMessage(() => {
return {
title,
path: options.path + (options.query ? `?${options.query}` : ''),
imageUrl: options.imageUrl,
}
})
onShareTimeline(() => {
return {
title,
path: options.path,
imageUrl: options.imageUrl,
query: options.query,
}
})
}
return {
setShareOptions,
}
}
+381 -18
View File
@@ -1,40 +1,403 @@
<script lang="ts" setup> <script lang="ts" setup>
import { systemInfo } from '@/utils/systemInfo'
import { useTokenStore } from '@/store/token' import { useTokenStore } from '@/store/token'
import { useGuestStore } from '@/store/guest'
import GuestInfoPanel from '@/components/post-detail/uh-guest-info-panel.vue'
defineOptions({ name: 'Login' })
definePage({ definePage({
style: { style: {
navigationBarTitleText: '登录', navigationBarTitleText: '登录',
navigationStyle: 'custom',
navigationBarBackgroundColor: '#ffffff',
}, },
}) })
const tokenStore = useTokenStore() const tokenStore = useTokenStore()
async function doLogin() { const guestStore = useGuestStore()
if (tokenStore.hasLogin) {
uni.navigateBack() // ---- Tab 切换 ----
return type AuthMode = 'login' | 'register'
const activeMode = ref<AuthMode>('login')
function switchMode(mode: AuthMode) {
activeMode.value = mode
// 切换时清空错误提示
clearErrors()
} }
try {
// 调用登录接口 // ---- 登录表单 ----
await tokenStore.login({ const loginForm = reactive({
username: '小莫唐尼', username: '',
password: '123456', password: '',
}) })
uni.navigateBack()
const loginErrors = reactive({
username: '',
password: '',
})
function validateLogin(): boolean {
let valid = true
loginErrors.username = ''
loginErrors.password = ''
if (!loginForm.username.trim()) {
loginErrors.username = '请输入用户名'
valid = false
}
if (!loginForm.password.trim()) {
loginErrors.password = '请输入密码'
valid = false
}
return valid
}
async function handleLogin() {
if (!validateLogin())
return
try {
await tokenStore.login({
username: loginForm.username.trim(),
password: loginForm.password.trim(),
})
handleLoginSuccess()
} }
catch (error) { catch (error) {
console.log('登录失败', error) console.error('登录失败:', error)
} }
} }
// ---- 注册表单 ----
const registerForm = reactive({
username: '',
password: '',
confirmPassword: '',
})
const registerErrors = reactive({
username: '',
password: '',
confirmPassword: '',
})
function validateRegister(): boolean {
let valid = true
registerErrors.username = ''
registerErrors.password = ''
registerErrors.confirmPassword = ''
if (!registerForm.username.trim()) {
registerErrors.username = '请输入用户名'
valid = false
}
if (!registerForm.password.trim()) {
registerErrors.password = '请输入密码'
valid = false
}
else if (registerForm.password.length < 6) {
registerErrors.password = '密码至少6位'
valid = false
}
if (!registerForm.confirmPassword.trim()) {
registerErrors.confirmPassword = '请确认密码'
valid = false
}
else if (registerForm.password !== registerForm.confirmPassword) {
registerErrors.confirmPassword = '两次密码不一致'
valid = false
}
return valid
}
async function handleRegister() {
if (!validateRegister())
return
try {
// TODO: 调用注册 API
// await register({
// username: registerForm.username.trim(),
// password: registerForm.password.trim(),
// })
uni.showToast({ title: '注册成功', icon: 'success' })
// 注册成功后切换到登录
switchMode('login')
loginForm.username = registerForm.username
loginForm.password = ''
}
catch (error) {
console.error('注册失败:', error)
}
}
function clearErrors() {
loginErrors.username = ''
loginErrors.password = ''
registerErrors.username = ''
registerErrors.password = ''
registerErrors.confirmPassword = ''
}
// ---- 密码可见性 ----
const showLoginPwd = ref(false)
const showRegisterPwd = ref(false)
const showConfirmPwd = ref(false)
// ---- 微信一键登录 ----
// #ifdef MP-WEIXIN
async function handleWxLogin() {
try {
await tokenStore.wxLogin()
handleLoginSuccess()
}
catch (error) {
console.error('微信登录失败:', error)
}
}
// #endif
// ---- 游客身份访问 ----
const showGuestPanel = ref(false)
function handleGuestAccess() {
showGuestPanel.value = true
}
function handleGuestSaved() {
showGuestPanel.value = false
uni.navigateBack()
}
// ---- 登录成功处理 ----
function handleLoginSuccess() {
// 检查是否有重定向地址(从路由拦截器传入)
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] as any
const redirect = currentPage?.$page?.options?.redirect || currentPage?.options?.redirect
if (redirect) {
uni.redirectTo({ url: decodeURIComponent(redirect) })
}
else {
uni.navigateBack()
}
}
// ---- 密码输入框切换可见性 ----
function togglePwdVisibility(target: 'login' | 'register' | 'confirm') {
if (target === 'login')
showLoginPwd.value = !showLoginPwd.value
else if (target === 'register')
showRegisterPwd.value = !showRegisterPwd.value
else showConfirmPwd.value = !showConfirmPwd.value
}
function handleBack() {
uni.navigateBack()
}
</script> </script>
<template> <template>
<view class="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 bg-page"> <view
<!-- 本页面是非MP的登录页主要用于 h5 APP --> class="box-border min-h-screen w-full flex flex-col items-center justify-center bg-page3 px-6"
<view class="text-center"> :class="[
登录页 systemInfo.uniPlatform ? 'pt-safe' : 'pt-12',
]"
>
<!-- 头部 Logo -->
<view
class="mb-8 mt-8 w-full flex flex-col items-center gap-y-2"
>
<image
class="h-16 w-16 rounded-2xl"
src="/static/logo.png"
mode="scaleToFill"
/>
<text class="text-xl text-gray-900 font-bold">欢迎回来</text>
<text class="text-xs text-gray-400">登录你的账号探索更多精彩</text>
</view> </view>
<button class="mt-4 w-40 text-center" @click="doLogin">
点击模拟登录 <!-- Tab 切换 -->
</button> <view class="mb-6 box-border w-full flex items-center rounded-xl bg-gray-100 p-1">
<view
class="center flex-1 rounded-lg py-2 text-sm font-medium transition-all"
:class="activeMode === 'login' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
@click="switchMode('login')"
>
登录
</view>
<view
class="center flex-1 rounded-lg py-2 text-sm font-medium transition-all"
:class="activeMode === 'register' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
@click="switchMode('register')"
>
注册
</view>
</view>
<!-- 登录表单 -->
<view v-if="activeMode === 'login'" class="w-full flex flex-col gap-y-4">
<!-- 用户名 -->
<view class="flex flex-col gap-y-1">
<view class="flex items-center rounded-xl bg-gray-100 px-4">
<wd-icon name="user" color="#9CA3AF" :size="16" />
<input
v-model="loginForm.username"
class="flex-1 py-2.5 pl-2 text-sm"
placeholder="请输入用户名"
:maxlength="30"
>
</view>
<text v-if="loginErrors.username" class="text-[11px] text-red-500">{{ loginErrors.username }}</text>
</view>
<!-- 密码 -->
<view class="flex flex-col gap-y-1">
<view class="flex items-center rounded-xl bg-gray-100 px-4">
<wd-icon name="lock" color="#9CA3AF" :size="16" />
<input
v-model="loginForm.password"
class="flex-1 py-2.5 pl-2 text-sm"
placeholder="请输入密码"
:password="!showLoginPwd"
:maxlength="30"
>
<view @click="togglePwdVisibility('login')">
<wd-icon :name="showLoginPwd ? 'view' : 'eye-close'" color="#9CA3AF" :size="16" />
</view>
</view>
<text v-if="loginErrors.password" class="text-[11px] text-red-500">{{ loginErrors.password }}</text>
</view>
<!-- 登录按钮 -->
<view
class="mt-2 h-11 w-full center rounded-xl bg-gray-900 text-sm text-white font-medium"
@click="handleLogin"
>
登录
</view>
<!-- 返回按钮 -->
<view
class="box-border h-10 w-full center gap-x-1 border border-gray-600 rounded-xl border-solid text-xs text-gray-600"
@click="handleBack"
>
暂不登录
</view>
</view>
<!-- 注册表单 -->
<view v-if="activeMode === 'register'" class="w-full flex flex-col gap-y-4">
<!-- 用户名 -->
<view class="flex flex-col gap-y-1">
<view class="flex items-center rounded-xl bg-gray-100 px-4">
<wd-icon name="user" color="#9CA3AF" :size="16" />
<input
v-model="registerForm.username"
class="flex-1 py-2.5 pl-2 text-sm"
placeholder="请输入用户名"
:maxlength="30"
>
</view>
<text v-if="registerErrors.username" class="text-[11px] text-red-500">{{ registerErrors.username }}</text>
</view>
<!-- 密码 -->
<view class="flex flex-col gap-y-1">
<view class="flex items-center rounded-xl bg-gray-100 px-4">
<wd-icon name="lock" color="#9CA3AF" :size="16" />
<input
v-model="registerForm.password"
class="flex-1 py-2.5 pl-2 text-sm"
placeholder="请输入密码(至少6位)"
:password="!showRegisterPwd"
:maxlength="30"
>
<view @click="togglePwdVisibility('register')">
<wd-icon :name="showRegisterPwd ? 'view' : 'eye-close'" color="#9CA3AF" :size="16" />
</view>
</view>
<text v-if="registerErrors.password" class="text-[11px] text-red-500">{{ registerErrors.password }}</text>
</view>
<!-- 确认密码 -->
<view class="flex flex-col gap-y-1">
<view class="flex items-center rounded-xl bg-gray-100 px-4">
<wd-icon name="lock" color="#9CA3AF" :size="16" />
<input
v-model="registerForm.confirmPassword"
class="flex-1 py-2.5 pl-2 text-sm"
placeholder="请再次输入密码"
:password="!showConfirmPwd"
:maxlength="30"
>
<view @click="togglePwdVisibility('confirm')">
<wd-icon :name="showConfirmPwd ? 'view' : 'eye-close'" color="#9CA3AF" :size="16" />
</view>
</view>
<text v-if="registerErrors.confirmPassword" class="text-[11px] text-red-500">{{ registerErrors.confirmPassword }}</text>
</view>
<!-- 注册按钮 -->
<view
class="mt-2 h-11 w-full center rounded-xl bg-gray-900 text-sm text-white font-medium"
@click="handleRegister"
>
注册
</view>
<!-- 返回按钮 -->
<view
class="box-border h-10 w-full center gap-x-1 border border-gray-600 rounded-xl border-solid text-xs text-gray-600"
@click="handleBack"
>
暂不登录
</view>
</view>
<!-- 分隔线 -->
<view class="my-6 w-full flex items-center justify-center gap-x-3">
<view class="h-px flex-1 bg-gray-100" />
<text class="text-xs text-gray-400">其他方式</text>
<view class="h-px flex-1 bg-gray-100" />
</view>
<!-- 第三方登录 & 游客访问 -->
<view class="w-full flex flex-col items-center gap-y-3">
<!-- #ifdef MP-WEIXIN -->
<view
class="h-10 w-full flex items-center justify-center gap-x-2 rounded-xl bg-[#07C160] text-sm text-white font-medium"
@click="handleWxLogin"
>
<wd-icon name="wechat" color="#ffffff" :size="18" />
<text>微信一键登录</text>
</view>
<!-- #endif -->
<view
class="w-full flex items-center justify-center gap-x-2 border border-gray-200 rounded-xl py-1 text-sm text-gray-600"
@click="handleGuestAccess"
>
<text>游客身份访问</text>
</view>
</view>
<!-- 底部协议提示 -->
<view class="mb-8 w-full flex flex-col items-center gap-y-1 pt-6">
<text class="text-[11px] text-gray-400">登录或注册即代表同意</text>
<view class="flex items-center gap-x-1">
<text class="text-[11px] text-gray-500 font-medium">用户协议</text>
<text class="text-[11px] text-gray-400"></text>
<text class="text-[11px] text-gray-500 font-medium">隐私政策</text>
</view>
</view>
<!-- 游客信息编辑面板 -->
<GuestInfoPanel
v-if="showGuestPanel"
@close="showGuestPanel = false"
@saved="handleGuestSaved"
/>
</view> </view>
</template> </template>
-30
View File
@@ -1,30 +0,0 @@
<script lang="ts" setup>
import { LOGIN_PAGE } from '@/router/config'
definePage({
style: {
navigationBarTitleText: '注册',
},
})
function doRegister() {
uni.showToast({
title: '注册成功',
})
// 注册成功后跳转到登录页
uni.navigateTo({
url: LOGIN_PAGE,
})
}
</script>
<template>
<view class="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 bg-page">
<view class="text-center">
注册页
</view>
<button class="mt-4 w-40 text-center" @click="doRegister">
点击模拟注册
</button>
</view>
</template>
-348
View File
@@ -1,348 +0,0 @@
<script lang="ts" setup>
import { getPicSumImages } from '@/utils/randomResources'
import { queryLinks } from '@/api/halo-plugin/link'
defineOptions({
name: 'Gallery',
})
definePage({
style: {
navigationBarTitleText: '相册',
navigationBarBackgroundColor: '#ffffff',
enablePullDownRefresh: true,
},
})
interface GalleryItem {
metadataName: string
displayName: string
url: string
}
interface GalleryGroup {
metadataName: string
displayName: string
images: GalleryItem[]
}
const groupBanner = reactive({
current: 0,
})
const galleryStyle = ref('list')
const showListPanel = reactive({
show: false,
metadataName: '',
})
// 相册分组预览
const galleryGroups = ref<GalleryGroup[]>([
{
metadataName: 'photo-group-HGaGs',
displayName: '我们的故事',
images: [{
metadataName: '',
displayName: '图片名称1',
url: getPicSumImages(800, 600),
}, {
metadataName: '',
displayName: '图片名称2',
url: getPicSumImages(800, 600),
}, {
metadataName: '',
displayName: '图片名称3',
url: getPicSumImages(800, 600),
}, {
metadataName: '',
displayName: '图片名称4',
url: getPicSumImages(800, 600),
}],
},
{
metadataName: '履行记录',
displayName: '履行记录',
images: [{
metadataName: '',
displayName: '图片名称1',
url: getPicSumImages(800, 600),
}, {
metadataName: '',
displayName: '图片名称2',
url: getPicSumImages(800, 600),
}, {
metadataName: '',
displayName: '图片名称3',
url: getPicSumImages(800, 600),
}],
},
{
metadataName: '家庭时光',
displayName: '家庭时光',
images: [{
metadataName: '',
displayName: '图片名称1',
url: getPicSumImages(800, 600),
}, {
metadataName: '',
displayName: '图片名称2',
url: getPicSumImages(800, 600),
}],
},
{
metadataName: '其他',
displayName: '其他',
images: [{
metadataName: '',
displayName: '图片名称1',
url: getPicSumImages(800, 600),
}],
},
])
// 相册图片
const galleryItems = ref<GalleryItem[]>([
{
metadataName: '',
displayName: '图片名称1',
url: getPicSumImages(400, 400),
},
{
metadataName: '',
displayName: '图片名称2',
url: getPicSumImages(800, 600),
},
{
metadataName: '',
displayName: '图片名称3',
url: getPicSumImages(400, 800),
},
{
metadataName: '',
displayName: '图片名称4',
url: getPicSumImages(1920, 1080),
},
{
metadataName: '',
displayName: '图片名称5',
url: getPicSumImages(800, 1920),
},
])
const { loading, error, data, run } = useRequest<any, IPaginationParams>(queryLinks, { loadingToast: false, loadingToastContent: '加载中...', loadingToastMask: true })
const { list: links, refresh } = useListData<any>({
params: {
page: 1,
size: 0,
},
loading: toRef(loading),
onPullDownRefresh: async (params, updateData) => {
await run({ ...params })
updateData({
error: error.value,
hasNext: data.value?.hasNext,
items: data.value?.items ?? [],
})
},
onReachBottom: async (params, updateData) => {
await run({ ...params })
updateData({
error: error.value,
hasNext: data.value?.hasNext,
items: data.value?.items ?? [],
})
},
})
function onGroupBannerChange(e: any) {
groupBanner.current = e.detail.current
}
function getGroupImageRotateClass(index: number) {
if (index === 0) {
return 'z-30 border-2 border-solid border-white uh-shadow-card group-hover:scale-95'
}
if (index === 1) {
return 'z-20 -rotate-6 group-hover:-rotate-22 border-2 border-solid border-white uh-shadow-card'
}
if (index === 2) {
return 'z-10 rotate-6 group-hover:rotate-22 border-2 border-solid border-white uh-shadow-card'
}
}
// -- 图片列表模式
// 图片列表容器样式
function getGalleryListContainerClass(total: number) {
if (total === 1) {
return 'h-36'
}
if (total === 2) {
return 'grid-cols-2 h-32'
}
if (total === 3) {
return 'grid-cols-2 grid-rows-2 h-36'
}
if (total === 4) {
return 'grid-cols-2 grid-rows-2 h-42'
}
return ''
}
// 图片项样式
function getGalleryListItemClass(index: number, total: number) {
if (total === 1) {
return ''
}
if (total === 2) {
return ''
}
if (total === 3) {
if (index === 0) {
return 'row-span-2'
}
return ''
}
if (total === 4) {
return ''
}
return ''
}
function handleShowListPanel(metadataName: string) {
showListPanel.metadataName = metadataName
showListPanel.show = true
}
onLoad(() => {
refresh()
})
onMounted(() => {
})
onPageScroll(() => { })
</script>
<template>
<view class="min-h-screen w-full flex flex-col bg-page">
<!-- 分组列表常态显示 -->
<view class="box-border w-full px-4 py-4">
<!-- 顶部区域固定区域 -->
<view class="relative w-full">
<swiper
autoplay circular class="h-46 w-full overflow-hidden rounded-xl" :current="groupBanner.current"
@change="onGroupBannerChange"
>
<swiper-item v-for="img in galleryGroups[0].images" :key="img.metadataName">
<image :src="img.url" class="h-full w-full object-cover" />
</swiper-item>
</swiper>
<view
class="absolute left-4 top-4 flex flex-col items-center justify-center rounded-2xl bg-orange-500 px-2 py-1.5 text-xs text-white font-bold"
>
最新精选
</view>
<view class="absolute bottom-4 left-4 flex flex-col gap-y-1">
<view class="text-sm text-white font-bold">
显示图片的名称
</view>
<view class="text-xs text-white/50">
显示描述/日期
</view>
<view class="w-full flex items-center justify-center gap-x-2">
<image
v-for="(item, index) in galleryGroups[0].images" :key="item.metadataName" :src="item.url"
mode="scaleToFill"
class="box-border h-8 w-8 border-2 border-white rounded-lg border-solid transition-all duration-300"
:class="[
{
'scale-110': groupBanner.current === index,
},
index % 2 === 0 ? '-rotate-4' : 'rotate-4',
]" @click="groupBanner.current = index"
/>
</view>
</view>
</view>
<!-- 分组列表区域 -->
<view class="mt-6 w-full flex items-center justify-between">
<text class="text-sm text-gray-900 font-bold">我的相册</text>
<text class="text-[10px] text-gray-600"> 30 组记录</text>
</view>
<!-- 混合模式 -->
<view v-if="galleryStyle === 'mix'" class="grid grid-cols-2 mt-4 w-full gap-4">
<view
v-for="group in galleryGroups" :key="group.metadataName"
class="group relative box-border h-36 w-full flex items-center justify-center p-1"
>
<!-- 预览图区域 -->
<view class="relative h-full w-full">
<view
v-for="(img, index) in group.images.slice(0, 3)" :key="img.metadataName"
class="absolute left-0 top-0 box-border h-full w-full origin-center overflow-hidden rounded-2xl transition-all duration-300"
:class="[getGroupImageRotateClass(index)]"
>
<image :src="img.url" mode="scaleToFill" class="h-full w-full" />
<!-- 文字区域 -->
<view
v-if="index === 0"
class="absolute bottom-0 left-0 right-0 z-40 w-full flex flex-col gap-y-0.5 from-transparent to-black/90 bg-gradient-to-b px-2 py-1.5"
>
<text class="text-xs text-white font-bold">{{ group.displayName }}</text>
<text class="text-[8px] text-white/80">{{ group.images.length }} 张图片</text>
</view>
</view>
</view>
</view>
</view>
<!-- 列表卡片模式 -->
<view v-else-if="galleryStyle === 'list'" class="mt-2 w-full flex flex-col gap-y-4">
<view
v-for="group in galleryGroups" :key="group.metadataName"
class="box-border flex flex-col gap-y-2 overflow-hidden rounded-xl bg-white p-4 pt-3" @click="handleShowListPanel(group.metadataName)"
>
<!-- 顶部区域 -->
<view class="flex items-center justify-between">
<view class="flex items-center gap-x-2 text-sm font-bold">
<view class="hidden h-2 w-2 rounded-full bg-gray-900" /> {{ group.displayName }}
</view>
<view class="shrink-0">
<text class="text-[10px] text-gray-400">{{ group.images.length }} </text>
</view>
</view>
<!-- 图片列表 -->
<view
class="grid gap-1 overflow-hidden rounded-lg"
:class="[getGalleryListContainerClass(group.images.slice(0, 4).length)]"
>
<view
v-for="(img, index) in group.images.slice(0, 4)" :key="img.metadataName"
class="overflow-hidden"
:class="getGalleryListItemClass(index, group.images.slice(0, 4).length)"
>
<image
:src="img.url" mode="scaleToFill"
class="h-full w-full object-cover transition-transform duration-500 hover:scale-105"
/>
</view>
</view>
</view>
</view>
</view>
<!-- 分组列表无数据显示 -->
<view class="hidden">
无数据
</view>
<!-- 相册列表根据分组显示 -->
<uh-gallery-panel v-if="showListPanel.show" :group-name="showListPanel.metadataName" @close="showListPanel.show = false" />
<!-- 底部导航占位 -->
<view class="w-full shrink-0">
<uh-tabbar-page-placeholder />
</view>
</view>
</template>
+15 -7
View File
@@ -1,9 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import useRequest from '@/hooks/useRequest' import { useAppShare } from '@/hooks/useShare'
import { useRequest } from '@/hooks/useRequest'
import { completeUrl } from '@/utils/url'
import { random } from '@/utils/base/random'
import { queryPhotoGroups, queryPhotos } from '@/api/halo-plugin/photo' import { queryPhotoGroups, queryPhotos } from '@/api/halo-plugin/photo'
import type { IPhoto, IPhotoGroup } from '@/api/halo-plugin/photo' import type { IPhoto, IPhotoGroup } from '@/api/halo-plugin/photo'
import { completeUrl } from '@/utils/url'
import random from '@/utils/base/random'
defineOptions({ defineOptions({
name: 'Gallery', name: 'Gallery',
@@ -11,7 +12,7 @@ defineOptions({
definePage({ definePage({
style: { style: {
navigationBarTitleText: '相册', navigationBarTitleText: '图库',
navigationBarBackgroundColor: '#ffffff', navigationBarBackgroundColor: '#ffffff',
enablePullDownRefresh: true, enablePullDownRefresh: true,
}, },
@@ -26,10 +27,17 @@ interface IGalleryGroup {
photos: IPhoto[] photos: IPhoto[]
} }
const { setShareOptions } = useAppShare()
setShareOptions({
title: '图库',
path: '/pages/gallery/gallery',
})
const groupBanner = reactive({ const groupBanner = reactive({
current: 0, current: 0,
}) })
const galleryStyle = ref('mix') const galleryStyle = ref('card')
const showListPanel = reactive({ const showListPanel = reactive({
show: false, show: false,
metadataName: '', metadataName: '',
@@ -180,9 +188,9 @@ onPageScroll(() => { })
</script> </script>
<template> <template>
<view class="bg-page3 min-h-screen w-full flex flex-col"> <view class="min-h-screen w-full flex flex-col bg-page3">
<!-- 测试 --> <!-- 测试 -->
<view class="box-border w-full p-4 pb-0"> <view v-if="false" class="box-border w-full p-4 pb-0">
<view class="uh-shadow-card box-border w-full flex items-center gap-x-4 rounded-xl bg-white p-4 text-sm"> <view class="uh-shadow-card box-border w-full flex items-center gap-x-4 rounded-xl bg-white p-4 text-sm">
<view class="shrink-0"> <view class="shrink-0">
{{ galleryStyle }} {{ galleryStyle }}
+14 -16
View File
@@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import useRequest from '@/hooks/useRequest' import { useAppShare } from '@/hooks/useShare'
import { useRequest } from '@/hooks/useRequest'
import { queryPostByName, queryPosts } from '@/api/halo-base/post' import { queryPostByName, queryPosts } from '@/api/halo-base/post'
import { cover } from '@/utils/imageHelper' import { cover } from '@/utils/imageHelper'
import { timeFrom } from '@/utils/base/timeFrom' import { timeFrom } from '@/utils/base/timeFrom'
@@ -23,6 +24,12 @@ definePage({
}, },
}) })
const { setShareOptions } = useAppShare()
setShareOptions({
path: '/pages/home/home',
})
type PostCardStyle = 'noCover' | 'leftCover' | 'bottomCover' | 'rightCover' | 'topCover' type PostCardStyle = 'noCover' | 'leftCover' | 'bottomCover' | 'rightCover' | 'topCover'
const postCardStyle = ref<PostCardStyle>('leftCover') const postCardStyle = ref<PostCardStyle>('leftCover')
@@ -127,18 +134,9 @@ onPageScroll(() => { })
</script> </script>
<template> <template>
<view class="bg-page3 min-h-screen"> <view class="min-h-screen bg-page3">
<!-- 顶部banner --> <!-- 顶部banner -->
<view v-if="false" class="relative h-72 w-full"> <view class="box-border w-full">
<image class="h-72 w-full object-cover" :src="exampleImageUrls.banner" />
<view class="absolute bottom-4 left-4 flex flex-col gap-y-2">
<text class="text-xs text-white">welcome to new world</text>
<text class="text-2xl text-white font-bold">UNI HALO V3.0 </text>
<text class="text-xs text-white">新全新探索简约的艺术风格</text>
</view>
</view>
<view>
<wd-swiper <wd-swiper
v-model:current="banner.current" :list="banner.items" indicator-position="right" autoplay v-model:current="banner.current" :list="banner.items" indicator-position="right" autoplay
:height="340" :height="340"
@@ -168,8 +166,8 @@ onPageScroll(() => { })
</view> </view>
<!-- 作者卡片 --> <!-- 作者卡片 -->
<view class="relative z-10 box-border px-4 -mt-12"> <view class="relative z-10 box-border px-4 -mt-12" @click="showPostCardModeSelector = !showPostCardModeSelector">
<profile-card @click="showPostCardModeSelector = !showPostCardModeSelector" /> <profile-card />
</view> </view>
<!-- 测试 --> <!-- 测试 -->
@@ -233,11 +231,11 @@ onPageScroll(() => { })
<text class="text-sm text-gray-900 font-bold">最新笔记</text> <text class="text-sm text-gray-900 font-bold">最新笔记</text>
</view> </view>
<view class="box-border w-full flex flex-col items-center justify-center gap-y-4 overflow-hidden"> <view class="box-border w-full flex flex-col items-center justify-center gap-y-4 overflow-hidden">
<view v-if="error && postList.length > 0" class="un-shadow-xs w-full rounded-2xl bg-white py-12"> <view v-if="error && postList.length > 0" class="un-shadow-xs box-border w-full rounded-2xl bg-white py-12">
<wd-empty :icon-size="60" icon="no-result" tip="暂无数据" /> <wd-empty :icon-size="60" icon="no-result" tip="暂无数据" />
</view> </view>
<template v-else> <template v-else>
<post-card v-for="post in postList" :key="post.metadata.name" :post="post" :type="postCardStyle" /> <post-card v-for="post in postList" :key="post.metadata.name" :post="post" :type="postCardStyle" class="w-full" />
</template> </template>
</view> </view>
</view> </view>
+11 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { getCurrentInstance } from 'vue' import { getCurrentInstance } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useAppShare } from '@/hooks/useShare'
import { useRequest } from '@/hooks/useRequest' import { useRequest } from '@/hooks/useRequest'
import { random } from '@/utils/base/random' import { random } from '@/utils/base/random'
import { queryLinkGroups, queryLinks } from '@/api/halo-plugin/link' import { queryLinkGroups, queryLinks } from '@/api/halo-plugin/link'
@@ -26,9 +27,16 @@ definePage({
}, },
}) })
const { setShareOptions } = useAppShare()
const { config } = storeToRefs(useUniHaloConfigStore()) const { config } = storeToRefs(useUniHaloConfigStore())
const { reset } = useUniHaloConfigStore() const { reset } = useUniHaloConfigStore()
setShareOptions({
title: '友情链接',
path: '/pages/links/links',
})
const { loading: loadingGroups, error: errorGroups, data: dataGroups, run: runGroups } = useRequest<ILinkGroup[]>(queryLinkGroups, { loadingToast: false, loadingToastContent: '加载中...', loadingToastMask: true }) const { loading: loadingGroups, error: errorGroups, data: dataGroups, run: runGroups } = useRequest<ILinkGroup[]>(queryLinkGroups, { loadingToast: false, loadingToastContent: '加载中...', loadingToastMask: true })
const { loading, error, data, run } = useRequest<IHaloListResponseBase<ILink>, IPaginationParams>(queryLinks, { loadingToast: false, loadingToastContent: '加载中...', loadingToastMask: true }) const { loading, error, data, run } = useRequest<IHaloListResponseBase<ILink>, IPaginationParams>(queryLinks, { loadingToast: false, loadingToastContent: '加载中...', loadingToastMask: true })
@@ -165,7 +173,7 @@ onLoad(async () => {
</script> </script>
<template> <template>
<view class="bg-page3 min-h-screen w-full flex flex-col"> <view class="min-h-screen w-full flex flex-col bg-page3">
<!-- 分组列表 --> <!-- 分组列表 -->
<view v-if="linkGroups.length !== 0" class="box-border w-full flex flex-col gap-y-6 overflow-hidden p-4 pb-0"> <view v-if="linkGroups.length !== 0" class="box-border w-full flex flex-col gap-y-6 overflow-hidden p-4 pb-0">
<view v-for="(item, index) in linkGroups" :key="item.name" class="flex flex-col gap-y-4"> <view v-for="(item, index) in linkGroups" :key="item.name" class="flex flex-col gap-y-4">
@@ -223,7 +231,7 @@ onLoad(async () => {
<view v-if="false" class="shrink-0"> <view v-if="false" class="shrink-0">
<view <view
class="uh-shadow-md rounded-xl bg-gray-900 px-4 py-1.5 text-xs text-white" class="uh-shadow-md rounded-xl bg-gray-900 px-4 py-1.5 text-xs text-white"
@click="copy(link.spec.url, true)" @click="copy(link.spec.url, '链接已复制')"
> >
复制 复制
</view> </view>
@@ -258,7 +266,7 @@ onLoad(async () => {
<button <button
v-if="config.base.isIndividual" v-if="config.base.isIndividual"
class="uh-shadow-md rounded-lg bg-gray-900 px-4 py-2 text-xs text-white outline-0 border-none!" class="uh-shadow-md rounded-lg bg-gray-900 px-4 py-2 text-xs text-white outline-0 border-none!"
@click="copy(link.spec.url, true)" @click="copy(link.spec.url, '链接已复制')"
> >
<wd-icon name="copy" :size="14" /> 复制 <wd-icon name="copy" :size="14" /> 复制
</button> </button>
+172 -84
View File
@@ -2,39 +2,92 @@
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { LOGIN_PAGE } from '@/router/config' import { LOGIN_PAGE } from '@/router/config'
import { useUserStore } from '@/store' import { useUserStore } from '@/store'
import { useGuestStore } from '@/store/guest'
import { useTokenStore } from '@/store/token' import { useTokenStore } from '@/store/token'
import i18n, { t } from '@/locale/index' import GuestInfoPanel from '@/components/post-detail/uh-guest-info-panel.vue'
import { setTabbarItem } from '@/tabbar/i18n'
defineOptions({ defineOptions({ name: 'Mine' })
name: 'Mine',
})
definePage({ definePage({
style: { style: {
navigationBarTitleText: '我的', navigationBarTitleText: '我的',
navigationBarBackgroundColor: '#ffffff', navigationBarBackgroundColor: '#ffffff',
enablePullDownRefresh: true,
}, },
}) })
const userStore = useUserStore() const userStore = useUserStore()
const tokenStore = useTokenStore() const tokenStore = useTokenStore()
// 使用storeToRefs解构userInfo const guestStore = useGuestStore()
const { userInfo } = storeToRefs(userStore) const { userInfo } = storeToRefs(userStore)
// 微信小程序下登录 /** 是否已登录 */
async function handleLogin() { const hasLogin = computed(() => tokenStore.hasLogin)
// #ifdef MP-WEIXIN /** 是否已填写游客信息 */
// 微信登录 const hasGuestInfo = computed(() => guestStore.isComplete)
await tokenStore.wxLogin()
// #endif /** 显示的头像 */
// #ifndef MP-WEIXIN const displayAvatar = computed(() => {
uni.navigateTo({ if (hasLogin.value && userInfo.value.avatar) {
url: `${LOGIN_PAGE}`, return userInfo.value.avatar
}
return '/static/images/default-avatar.png'
}) })
// #endif
/** 显示的名称 */
const displayName = computed(() => {
if (hasLogin.value) {
return userInfo.value.nickname || userInfo.value.username
}
if (hasGuestInfo.value) {
return guestStore.info.displayName
}
return '未登录'
})
/** 显示的副标题 */
const displaySubtitle = computed(() => {
if (hasLogin.value) {
return userInfo.value.role || userInfo.value.username
}
if (hasGuestInfo.value) {
return guestStore.info.email
}
return '登录后解锁更多功能'
})
/** 用户状态标签 */
const statusLabel = computed(() => {
if (hasLogin.value)
return '已登录'
if (hasGuestInfo.value)
return '游客'
return '未登录'
})
const statusColor = computed(() => {
if (hasLogin.value)
return 'bg-green-50 text-green-600'
if (hasGuestInfo.value)
return 'bg-amber-50 text-amber-600'
return 'bg-gray-100 text-gray-500'
})
// ---- 游客信息编辑 ----
const showGuestPanel = ref(false)
function handleEditGuest() {
showGuestPanel.value = true
}
function handleGuestSaved() {
showGuestPanel.value = false
uni.showToast({ title: '保存成功', icon: 'success' })
}
// ---- 登录/退出 ----
function handleLogin() {
uni.navigateTo({ url: LOGIN_PAGE })
} }
function handleLogout() { function handleLogout() {
@@ -43,93 +96,121 @@ function handleLogout() {
content: '确定要退出登录吗?', content: '确定要退出登录吗?',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
// 清空用户信息 tokenStore.logout()
useTokenStore().logout() uni.showToast({ title: '已退出登录', icon: 'success' })
// 执行退出登录逻辑
uni.showToast({
title: '退出登录成功',
icon: 'success',
})
// #ifdef MP-WEIXIN
// 微信小程序,去首页
// uni.reLaunch({ url: '/pages/index/index' })
// #endif
// #ifndef MP-WEIXIN
// 非微信小程序,去登录页
// uni.navigateTo({ url: LOGIN_PAGE })
// #endif
} }
}, },
}) })
} }
const current = ref(uni.getLocale()) // ---- 功能导航 ----
const languages = [ interface NavItem {
{ icon: string
value: 'zh-Hans', label: string
name: '中文', path?: string
checked: 'true', showArrow?: boolean
}, }
{
value: 'en', const navList: NavItem[] = [
name: '英文', { icon: 'file', label: '免责声明', path: '', showArrow: true },
}, { icon: 'chat', label: '在线客服', path: '', showArrow: true },
{ icon: 'edit', label: '意见反馈', path: '', showArrow: true },
{ icon: 'info-circle', label: '关于项目', path: '/subpkg-blog/about/about', showArrow: true },
] ]
function radioChange(evt) { function handleNavClick(item: NavItem) {
// console.log(evt) if (item.path) {
current.value = evt.detail.value uni.navigateTo({ url: item.path })
// 下面2句缺一不可!!! }
uni.setLocale(evt.detail.value) else {
i18n.global.locale = evt.detail.value uni.showToast({ title: '即将开放,敬请期待', icon: 'none' })
}
// 底部tabbar需要重新设置一下
setTabbarItem()
// 本页的标题也需要重新设置一下
uni.setNavigationBarTitle({
title: t('i18n.title'),
})
} }
</script> </script>
<template> <template>
<view class="bg-page3 box-border min-h-screen w-full flex flex-col items-center justify-center gap-y-6 px-4"> <view class="box-border min-h-screen w-full flex flex-col gap-y-4 bg-page3 px-4 pt-4">
<view class="mt-3 break-all px-3 text-center text-green-500"> <!-- 用户信息卡片 -->
{{ userInfo.username ? '已登录' : '未登录' }} <view class="uh-shadow-xs box-border flex flex-col rounded-2xl bg-white p-4">
<view class="w-full flex items-center gap-x-3">
<!-- 头像 -->
<image
class="uh-shadow-xs h-16 w-16 shrink-0 border-2 border-white rounded-full border-solid"
:src="displayAvatar"
mode="scaleToFill"
/>
<!-- 信息区 -->
<view class="flex flex-1 flex-col gap-y-1">
<view class="flex items-center gap-x-2">
<text class="text-base text-gray-900 font-bold">{{ displayName }}</text>
<view class="rounded-full px-2 py-0.5" :class="statusColor">
<text class="text-[10px] font-medium">{{ statusLabel }}</text>
</view> </view>
<view class="mt-3 break-all px-3">
{{ JSON.stringify(userInfo, null, 2) }}
</view> </view>
<text class="line-clamp-1 text-xs text-gray-500">{{ displaySubtitle }}</text>
<!-- 切换语言 -->
<view class="mt-6 w-full flex flex-col items-center justify-center">
<view class="mb-2 text-gray-900 font-bold">
切换语言
</view> </view>
<view class="w-full flex items-center justify-center gap-4"> <!-- 游客编辑入口 -->
<radio-group class="flex flex-col items-center justify-center gap-2" @change="radioChange"> <view
<label v-if="!hasLogin && hasGuestInfo"
v-for="item in languages" class="h-8 w-8 center shrink-0 rounded-full bg-gray-50"
:key="item.value" @click="handleEditGuest"
class="flex items-center gap-x-2"
> >
<view> <wd-icon name="edit" color="#6B7280" :size="16" />
<radio :value="item.value" :checked="item.value === current" />
</view>
<view>{{ item.name }}</view>
</label>
</radio-group>
</view> </view>
</view> </view>
<view class="mt-6"> <!-- 游客信息快捷编辑入口未登录且未填写游客信息时 -->
<view class="m-auto w-160px text-center"> <view
<button v-if="tokenStore.hasLogin" type="warn" class="w-full rounded-xl text-xs" @click="handleLogout"> v-if="!hasLogin && !hasGuestInfo"
class="mt-3 box-border flex items-center gap-x-2 rounded-xl bg-gray-50 px-3 py-2.5"
@click="handleEditGuest"
>
<wd-icon name="edit" color="#9CA3AF" :size="14" />
<text class="text-xs text-gray-400">填写游客信息用于评论互动</text>
<wd-icon name="arrow-right" color="#D1D5DB" :size="14" />
</view>
<!-- 已登录时显示的额外信息 -->
<view v-if="hasLogin" class="my-1 mt-3 h-px w-full bg-gray-50" />
<view v-if="hasLogin" class="flex items-center justify-between">
<text class="text-xs text-gray-400">账号{{ userInfo.username }}</text>
</view>
</view>
<!-- 功能导航 -->
<view class="uh-shadow-xs box-border flex flex-col rounded-2xl bg-white">
<view
v-for="(item, index) in navList"
:key="item.label"
class="box-border flex items-center justify-between px-4 py-3.5"
:class="{ 'border-b border-gray-50': index < navList.length - 1 }"
@click="handleNavClick(item)"
>
<view class="flex items-center gap-x-3">
<view class="h-8 w-8 center rounded-xl bg-gray-50">
<wd-icon :name="item.icon" color="#6B7280" :size="16" />
</view>
<text class="text-sm text-gray-800">{{ item.label }}</text>
</view>
<wd-icon v-if="item.showArrow" name="arrow-right" color="#D1D5DB" :size="14" />
</view>
</view>
<!-- 登录/退出按钮 -->
<view class="mt-2 w-full">
<view
v-if="hasLogin"
class="uh-shadow-xs h-11 w-full center rounded-2xl bg-white text-sm text-red-500 font-medium"
@click="handleLogout"
>
退出登录 退出登录
</button> </view>
<button v-else class="w-full rounded-xl bg-gray-900 py-2.5 text-xs text-white" @click="handleLogin"> <view
v-else
class="h-11 w-full center rounded-2xl bg-gray-900 text-sm text-white font-medium"
@click="handleLogin"
>
立即登录 立即登录
</button>
</view> </view>
</view> </view>
@@ -137,5 +218,12 @@ function radioChange(evt) {
<view class="w-full shrink-0"> <view class="w-full shrink-0">
<uh-tabbar-page-placeholder /> <uh-tabbar-page-placeholder />
</view> </view>
<!-- 游客信息编辑面板 -->
<GuestInfoPanel
v-if="showGuestPanel"
@close="showGuestPanel = false"
@saved="handleGuestSaved"
/>
</view> </view>
</template> </template>
+11 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useAppShare } from '@/hooks/useShare'
import { cover } from '@/utils/imageHelper' import { cover } from '@/utils/imageHelper'
import { queryMoments } from '@/api/halo-plugin/moment' import { queryMoments } from '@/api/halo-plugin/moment'
import { parseDateTime } from '@/utils/base/time' import { parseDateTime } from '@/utils/base/time'
@@ -17,7 +18,14 @@ definePage({
}, },
}) })
const useTimeline = ref(true) const { setShareOptions } = useAppShare()
setShareOptions({
title: '瞬间',
path: '/pages/moments/moments',
})
const useTimeline = ref(false)
const { loading, error, data, run } = useRequest<IHaloListResponseBase<IMoment>, IMomentListRequest>(queryMoments, { loadingToast: false, loadingToastContent: '加载中...', loadingToastMask: true }) const { loading, error, data, run } = useRequest<IHaloListResponseBase<IMoment>, IMomentListRequest>(queryMoments, { loadingToast: false, loadingToastContent: '加载中...', loadingToastMask: true })
@@ -63,9 +71,9 @@ onPageScroll(() => { })
</script> </script>
<template> <template>
<view class="bg-page3 min-h-screen w-full flex flex-col"> <view class="min-h-screen w-full flex flex-col bg-page3">
<!-- 测试 --> <!-- 测试 -->
<view class="box-border w-full p-4 pb-0"> <view v-if="false" class="box-border w-full p-4 pb-0">
<view class="uh-shadow-card box-border w-full flex items-center gap-x-4 rounded-xl bg-white p-4 text-sm"> <view class="uh-shadow-card box-border w-full flex items-center gap-x-4 rounded-xl bg-white p-4 text-sm">
<view class="shrink-0"> <view class="shrink-0">
模式{{ useTimeline }} 模式{{ useTimeline }}
+2 -2
View File
@@ -9,9 +9,9 @@ export const LOGIN_STRATEGY = LOGIN_STRATEGY_MAP.DEFAULT_NO_NEED_LOGIN
export const isNeedLoginMode = LOGIN_STRATEGY === LOGIN_STRATEGY_MAP.DEFAULT_NEED_LOGIN export const isNeedLoginMode = LOGIN_STRATEGY === LOGIN_STRATEGY_MAP.DEFAULT_NEED_LOGIN
export const LOGIN_PAGE = '/pages/auth/login' export const LOGIN_PAGE = '/pages/auth/login'
export const REGISTER_PAGE = '/pages/auth/register' export const REGISTER_PAGE = '/pages/auth/login'
export const LOGIN_PAGE_LIST = [LOGIN_PAGE, REGISTER_PAGE] export const LOGIN_PAGE_LIST = [LOGIN_PAGE]
// 在 definePage 里面配置了 excludeLoginPath 的页面,功能与 EXCLUDE_LOGIN_PATH_LIST 相同 // 在 definePage 里面配置了 excludeLoginPath 的页面,功能与 EXCLUDE_LOGIN_PATH_LIST 相同
export const excludeLoginPathList = getAllPages('excludeLoginPath').map(page => page.path) export const excludeLoginPathList = getAllPages('excludeLoginPath').map(page => page.path)
+4
View File
@@ -4,8 +4,12 @@ import type { IUniHaloConfig } from '@/api/halo-plugin/uni-halo'
import { deepMerge } from '@/utils/base/deepMerge' import { deepMerge } from '@/utils/base/deepMerge'
const defaultConfig: IUniHaloConfig = { const defaultConfig: IUniHaloConfig = {
app: {
name: 'uni-halo',
},
base: { base: {
domain: import.meta.env.VITE_UNI_HALO_DOMAIN, domain: import.meta.env.VITE_UNI_HALO_DOMAIN,
token: import.meta.env.VITE_UNI_HALO_TOKEN,
isIndividual: false, isIndividual: false,
}, },
home: { home: {
+48
View File
@@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
export interface IGuestInfo {
displayName: string
email: string
website: string
}
const GuestStoreKey = 'UNI_HALO_GUEST_INFO'
const defaultGuestInfo: IGuestInfo = {
displayName: '',
email: '',
website: '',
}
/**
* 游客信息状态管理
*/
export const useGuestStore = defineStore('guest', () => {
const info = ref<IGuestInfo>({ ...defaultGuestInfo, ...(uni.getStorageSync(GuestStoreKey) ?? {}) })
/** 是否已填写游客信息 */
const isComplete = computed(() => {
return !!(info.value.displayName.trim() && info.value.email.trim())
})
/** 保存游客信息 */
function save(data: IGuestInfo) {
info.value = { ...data }
uni.setStorageSync(GuestStoreKey, info.value)
}
/** 清除游客信息 */
function clear() {
info.value = { ...defaultGuestInfo }
uni.removeStorageSync(GuestStoreKey)
}
return {
info,
isComplete,
save,
clear,
}
}, {
persist: false, // 手动管理持久化
})
+47
View File
@@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { downvote, upvote } from '@/api/halo-base/trackers'
const UpvoteStoreKey = 'UNI_HALO_UPVOTED_SET'
/**
* 全局点赞状态管理
* 使用 metadata.name 作为 key,持久化存储
*/
export const useUpvoteStore = defineStore('upvote', () => {
const upvotedSet = ref<Set<string>>(new Set(uni.getStorageSync(UpvoteStoreKey) ?? []))
/** 是否已点赞 */
function isUpvoted(name: string): boolean {
return upvotedSet.value.has(name)
}
/** 切换点赞状态 */
async function toggle(name: string, plural: 'comments' | 'posts' = 'comments'): Promise<boolean> {
const alreadyUpvoted = upvotedSet.value.has(name)
try {
if (alreadyUpvoted) {
await downvote({ group: 'content.halo.run', plural, name })
upvotedSet.value.delete(name)
}
else {
await upvote({ group: 'content.halo.run', plural, name })
upvotedSet.value.add(name)
}
// 持久化
uni.setStorageSync(UpvoteStoreKey, Array.from(upvotedSet.value))
return !alreadyUpvoted
}
catch {
// 接口不返回内容,静默处理
return alreadyUpvoted
}
}
return {
upvotedSet,
isUpvoted,
toggle,
}
}, {
persist: false, // 手动管理持久化
})
+3 -3
View File
@@ -11,11 +11,11 @@
} }
.uh-backdrop-blur { .uh-backdrop-blur {
backdrop-filter: blur(4rpx); backdrop-filter: blur(2rpx);
} }
.uh-backdrop-blur-xs { .uh-backdrop-blur-xs {
backdrop-filter: blur(8rpx); backdrop-filter: blur(4rpx);
} }
.uh-backdrop-blur-sm { .uh-backdrop-blur-sm {
@@ -23,5 +23,5 @@
} }
.uh-backdrop-blur-md { .uh-backdrop-blur-md {
backdrop-filter: blur(24rpx); backdrop-filter: blur(16rpx);
} }
+382 -3
View File
@@ -1,5 +1,21 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRequest } from '@/hooks/useRequest'
import { markdownConfig } from '@/config/markdown/config' import { markdownConfig } from '@/config/markdown/config'
import { useUniHaloConfigStore } from '@/store/config'
import { queryPostByName, queryPostNavigationByName } from '@/api/halo-base/post'
import { completeUrl } from '@/utils/url'
import { cover } from '@/utils/imageHelper'
import { timeFrom } from '@/utils/base/timeFrom'
import { formatNumberUnit } from '@/utils/base/digital'
import { useUpvoteStore } from '@/store/upvote'
import type { NavigationPostVo, PostV1alpha1PublicApiQueryPostByNameRequest, PostVo } from '@halo-dev/api-client'
import mpHtml from '@/components/mp-html/components/mp-html/mp-html.vue'
import UhPostCommentPanel from '@/components/post-detail/uh-post-comment-panel.vue'
import copy from '@/utils/base/clipboard'
interface IPostDetailLoadOptions {
metadataName: string
}
defineOptions({ defineOptions({
name: 'PostDetail', name: 'PostDetail',
@@ -8,14 +24,377 @@ defineOptions({
definePage({ definePage({
style: { style: {
navigationBarTitleText: '文章详情', navigationBarTitleText: '文章详情',
navigationBarBackgroundColor: '#ffffff', navigationStyle: 'custom',
enablePullDownRefresh: true, enablePullDownRefresh: true,
}, },
}) })
const uniHaloConfigStore = useUniHaloConfigStore()
// --- 数据请求 ---
const { loading, data: post, run: fetchPost } = useRequest<PostVo, PostV1alpha1PublicApiQueryPostByNameRequest>(queryPostByName, { loadingToast: false, loadingToastContent: '加载中...' })
const { data: navigation, run: fetchNavigation } = useRequest<NavigationPostVo, PostV1alpha1PublicApiQueryPostByNameRequest>(queryPostNavigationByName)
let postName = ''
const upvoteStore = useUpvoteStore()
// --- 点赞计数 ---
const upvoteCount = ref(0)
const isUpvoted = computed(() => upvoteStore.isUpvoted(postName))
// --- 计算属性 ---
const coverUrl = computed(() => {
const raw = post.value?.spec?.cover
return raw ? cover(raw) : ''
})
const categories = computed(() => {
return post.value?.categories ?? []
})
const tags = computed(() => {
return post.value?.tags ?? []
})
const publishTime = computed(() => {
return timeFrom(post.value?.spec?.publishTime ?? null)
})
const stats = computed(() => {
return {
visit: formatNumberUnit(post.value?.stats?.visit ?? 0),
comment: formatNumberUnit(post.value?.stats?.comment ?? 0),
upvote: formatNumberUnit(upvoteCount.value || (post.value?.stats?.upvote ?? 0)),
}
})
const htmlContent = computed(() => {
return post.value?.content?.content ?? ''
})
const prevPost = computed(() => navigation.value?.previous)
const nextPost = computed(() => navigation.value?.next)
// --- 方法 ---
function handleBack() {
uni.navigateBack()
}
async function handleUpvote() {
const nowUpvoted = await upvoteStore.toggle(postName, 'posts')
upvoteCount.value = Math.max(0, (post.value?.stats?.upvote ?? 0) + (nowUpvoted ? 1 : -1))
}
function handleShare() {
// #ifdef MP-WEIXIN
// 微信小程序中由页面右上角菜单处理分享
// #endif
// #ifndef MP-WEIXIN
copy(post.value?.status?.permalink ?? '', '链接已复制')
// #endif
}
function handleScrollToTop() {
uni.pageScrollTo({ scrollTop: 0, duration: 300 })
}
function handleNavigatePost(name: string) {
postName = name
fetchPost({ name })
fetchNavigation({ name })
handleScrollToTop()
}
async function loadData() {
await fetchPost({ name: postName })
fetchNavigation({ name: postName })
upvoteCount.value = post.value?.stats?.upvote ?? 0
}
// --- 返回顶部 ---
const showBackTop = ref(false)
let beforeScrollTop = 0
onPageScroll(({ scrollTop }) => {
showBackTop.value = scrollTop > 300 && scrollTop > beforeScrollTop
beforeScrollTop = scrollTop
})
// --- 生命周期 ---
onLoad((options: IPostDetailLoadOptions) => {
postName = options.metadataName
loadData()
})
onPullDownRefresh(async () => {
await loadData()
uni.stopPullDownRefresh()
})
// 微信小程序分享
onShareAppMessage(() => {
return {
title: post.value?.spec?.title ?? '文章详情',
path: `/subpkg-blog/post-detail/post-detail?metadataName=${postName}`,
imageUrl: coverUrl.value,
}
})
onShareTimeline(() => {
return {
title: post.value?.spec?.title ?? '文章详情',
path: `/subpkg-blog/post-detail/post-detail`,
query: `metadataName=${postName}`,
imageUrl: coverUrl.value,
}
})
// --- 评论面板 ---
const showCommentPanel = ref(false)
function handleOpenCommentPanel() {
showCommentPanel.value = true
}
function handleScrollContentToTop() {
uni.pageScrollTo({ scrollTop: 236, duration: 300 })
}
</script> </script>
<template> <template>
<view class="min-h-screen w-full"> <view class="min-h-screen w-full flex flex-col bg-page3">
文章详情页面 <!-- 顶部封面区域 -->
<view class="fixed left-0 right-0 top-0 z-10 h-62 w-full overflow-hidden">
<image v-if="coverUrl" class="h-full w-full" mode="aspectFill" :src="coverUrl" />
<view v-else class="h-full w-full bg-gray-200" />
<!-- 渐变遮罩 -->
<view class="absolute bottom-0 left-0 right-0 z-10 h-3/5 from-black/0 via-page/50 to-page3 bg-gradient-to-b" />
</view> </view>
<!-- 文章信息卡片覆盖封面底部 -->
<view class="relative z-20 box-border w-full flex flex-col gap-y-4 pt-62 -mt-8">
<!-- 主信息卡片 -->
<view
v-if="htmlContent"
class="uh-shadow-xs box-border w-full flex flex-col gap-y-3 overflow-hidden rounded-3xl bg-white"
>
<!-- 操作条 -->
<view class="w-full flex items-center justify-center pt-3" @click="handleScrollContentToTop">
<view class="box-border h-4px w-8 rounded-full bg-gray-200 transition-colors duration-300 hover:bg-gray-600" />
</view>
<!-- 标题 -->
<text class="box-border p-4 pb-0 pt-0 text-lg text-gray-900 font-bold leading-6">
{{ post?.spec?.title ?? '加载中...' }}
</text>
<!-- 分类 & 标签 -->
<view v-if="categories.length > 0 || tags.length > 0" class="box-border flex flex-wrap items-center gap-2 px-4">
<view
v-for="cat in categories" :key="cat.metadata.name"
class="rounded-full bg-gray-900 px-2.5 py-1 text-xs text-white font-medium"
>
{{ cat.spec?.displayName }}
</view>
<view
v-for="tag in tags" :key="tag.metadata.name"
class="border border-gray-200 rounded-full bg-white px-2.5 py-1 text-xs text-gray-500"
>
# {{ tag.spec?.displayName }}
</view>
</view>
<!-- 元信息 -->
<view class="box-border flex flex-1 items-center gap-x-1 px-4">
<text class="text-xs text-gray-500">{{ publishTime }}</text>
<text class="text-xs text-gray-400">·</text>
<text class="text-xs text-gray-500">浏览 {{ stats.visit }}</text>
<text class="text-xs text-gray-400">·</text>
<text class="text-xs text-gray-500">评论 {{ stats.comment }}</text>
<text class="text-xs text-gray-400">·</text>
<text class="text-xs text-gray-500">点赞 {{ stats.upvote }}</text>
<text class="text-xs text-gray-400">·</text>
<text class="text-xs text-gray-500">字数 {{ formatNumberUnit(htmlContent.length) }}</text>
</view>
<!-- 分割线 -->
<view class="box-border h-1px w-full bg-gray-50 px-4" />
<!-- 作者信息 -->
<view v-if="post?.owner" class="box-border flex items-center gap-x-2 hidden!">
<image
v-if="post.owner.avatar" class="h-8 w-8 border-2 border-white rounded-full border-solid"
:src="completeUrl(post.owner.avatar)" mode="aspectFill"
/>
<view class="flex flex-col">
<text class="text-xs text-gray-900 font-medium">{{ post.owner.displayName }}</text>
<text v-if="post.owner.bio" class="line-clamp-1 text-[11px] text-gray-400">{{ post.owner.bio }}</text>
</view>
</view>
<!-- 文章正文 -->
<view class="box-border px-1">
<mp-html
:content="htmlContent" :tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle" :domain="markdownConfig.domain" lazy-load selectable
:scroll-table="true" :use-anchor="true"
/>
</view>
<!-- 上一篇/下一篇导航 -->
<view v-if="prevPost || nextPost" class="box-border w-full">
<view
class="box-border w-full flex flex-col gap-y-2 border-0 border-gray-100 rounded-xl border-solid px-4"
>
<!-- 上一篇 -->
<view
v-if="prevPost" class="flex items-center gap-x-3 rounded-xl bg-gray-50 p-3"
@click="handleNavigatePost(prevPost.metadata.name)"
>
<view class="h-10 w-10 shrink-0 overflow-hidden rounded-lg">
<image
class="box-border h-full w-full border-2 border-gray-50 rounded-lg border-solid bg-gray-200"
mode="aspectFill" :src="cover(prevPost.spec?.cover)"
/>
</view>
<view class="flex flex-1 flex-col gap-y-0.5 overflow-hidden">
<text class="text-[11px] text-gray-400">上一篇</text>
<text class="line-clamp-1 text-xs text-gray-900 font-medium">{{ prevPost.spec?.title }}</text>
</view>
<wd-icon name="arrow-right" color="#9CA3AF" :size="16" />
</view>
<!-- 下一篇 -->
<view
v-if="nextPost" class="flex items-center gap-x-3 rounded-xl bg-gray-50 p-3"
@click="handleNavigatePost(nextPost.metadata.name)"
>
<view class="h-10 w-10 shrink-0 overflow-hidden rounded-lg">
<image
class="box-border h-full w-full border-2 border-gray-50 rounded-lg border-solid bg-gray-200"
mode="aspectFill" :src="cover(nextPost.spec?.cover)"
/>
</view>
<view class="flex flex-1 flex-col gap-y-0.5 overflow-hidden">
<text class="text-[11px] text-gray-400">下一篇</text>
<text class="line-clamp-1 text-xs text-gray-900 font-medium">{{ nextPost.spec?.title }}</text>
</view>
<wd-icon name="arrow-right" color="#9CA3AF" :size="16" />
</view>
</view>
</view>
<!-- 底部安全区占位 -->
<uh-tabbar-page-placeholder />
</view>
<!-- 加载中占位 -->
<view v-else-if="loading" class="box-border h-full w-full center py-26">
<wd-loading :size="32" color="#1a1a1a">
加载中...
</wd-loading>
</view>
<!-- 上一篇/下一篇导航 -->
<view
v-if="prevPost || nextPost"
class="uh-shadow-xs box-border w-full flex flex-col gap-y-2 rounded-2xl bg-white p-4 hidden!"
>
<view class="mb-1 text-sm text-gray-900 font-bold">
文章导航
</view>
<!-- 上一篇 -->
<view
v-if="prevPost" class="flex items-center gap-x-3 rounded-xl bg-gray-50 p-3"
@click="handleNavigatePost(prevPost.metadata.name)"
>
<view class="h-10 w-10 shrink-0 overflow-hidden rounded-lg">
<image class="h-full w-full" mode="aspectFill" :src="cover(prevPost.spec?.cover)" />
</view>
<view class="flex flex-1 flex-col gap-y-0.5 overflow-hidden">
<text class="text-[11px] text-gray-400">上一篇</text>
<text class="line-clamp-1 text-xs text-gray-900 font-medium">{{ prevPost.spec?.title }}</text>
</view>
<wd-icon name="arrow-right" color="#9CA3AF" :size="16" />
</view>
<!-- 下一篇 -->
<view
v-if="nextPost" class="flex items-center gap-x-3 rounded-xl bg-gray-50 p-3"
@click="handleNavigatePost(nextPost.metadata.name)"
>
<view class="h-10 w-10 shrink-0 overflow-hidden rounded-lg">
<image class="h-full w-full" mode="aspectFill" :src="cover(nextPost.spec?.cover)" />
</view>
<view class="flex flex-1 flex-col gap-y-0.5 overflow-hidden">
<text class="text-[11px] text-gray-400">下一篇</text>
<text class="line-clamp-1 text-xs text-gray-900 font-medium">{{ nextPost.spec?.title }}</text>
</view>
<wd-icon name="arrow-right" color="#9CA3AF" :size="16" />
</view>
</view>
</view>
<!-- 底部浮动操作栏iOS 26 风格 -->
<view class="fixed bottom-6 left-4 right-4 z-100 flex items-center gap-x-4" @touchmove.stop.prevent>
<!-- 左侧操作栏 -->
<view
class="uh-shadow-md uh-backdrop-blur box-border h-46px flex flex-1 items-center justify-around border border-white rounded-full border-solid bg-white/95 p-1"
>
<!-- 返回 -->
<view class="flex flex-1 items-center justify-center gap-x-0.5" @click="handleBack">
<view class="center rounded-full transition-colors">
<wd-icon name="arrow-left" color="#6B7280" :size="18" />
</view>
<text class="text-xs text-gray-900 font-medium">
返回
</text>
</view>
<!-- 点赞 -->
<view class="flex flex-1 items-center justify-center gap-x-1" @click="handleUpvote">
<view class="center rounded-full transition-colors" :class="isUpvoted ? 'bg-red-50' : 'bg-transparent'">
<wd-icon name="thumb-up" :color="isUpvoted ? '#EF4444' : '#6B7280'" :size="18" />
</view>
<text class="text-xs font-medium" :class="isUpvoted ? 'text-red-500' : 'text-gray-900'">
{{ stats.upvote }}
</text>
</view>
<!-- 评论 -->
<view class="flex flex-1 items-center justify-center gap-x-1" @click="handleOpenCommentPanel">
<view class="center rounded-full bg-transparent">
<wd-icon name="message" color="#6B7280" :size="18" />
</view>
<text class="text-xs text-gray-900 font-medium">{{ stats.comment }}</text>
</view>
<!-- 分享 -->
<button open-type="share" class="flex flex-1 items-center justify-center gap-x-1 bg-transparent after:border-0 border-0! px-0! outline-0!" @click="handleShare">
<view class="center rounded-full bg-transparent">
<wd-icon name="share-alt" color="#6B7280" :size="18" />
</view>
<text class="text-xs text-gray-900 font-medium">分享</text>
</button>
</view>
<!-- 右侧返回顶部 -->
<view
class="uh-shadow-md uh-backdrop-blur box-border h-46px w-46px center flex shrink-0 border border-white rounded-full border-solid bg-white/95"
@click="handleScrollToTop"
>
<wd-icon name="arrow-up" color="#1A1A1A" :size="18" />
</view>
</view>
</view>
<!-- 评论面板 -->
<uh-post-comment-panel
v-if="showCommentPanel" :post-name="postName"
:comment-subject-version="post?.spec?.releaseSnapshot ?? ''" :comment-count="post?.stats?.comment ?? 0"
@close="showCommentPanel = false" @commented="loadData"
/>
</template> </template>
+4 -4
View File
@@ -1,16 +1,16 @@
/** /**
* 复制文本到剪贴板 * 复制文本到剪贴板
* @param {string} content 要复制的文本 * @param {string} content 要复制的文本
* @param {boolean} showToast 是否显示复制成功提示 * @param {string} toastText 复制成功提示内容
*/ */
export function copy(content: string, showToast: boolean = false) { export function copy(content: string, toastText?: string) {
uni.setClipboardData({ uni.setClipboardData({
data: content, data: content,
showToast: false, showToast: false,
success: () => { success: () => {
if (showToast) { if (toastText) {
uni.showToast({ uni.showToast({
title: '复制成功', title: toastText,
icon: 'none', icon: 'none',
}) })
} }
+22 -2
View File
@@ -1,5 +1,5 @@
import { completeUrl } from '@/utils/url' import { completeUrl } from '@/utils/url'
import { getRandomUrl } from '@/utils/randomResources' import { getCrAvatar, getRandomUrl } from '@/utils/randomResources'
/** /**
* 图片封面处理 * 图片封面处理
@@ -7,10 +7,30 @@ import { getRandomUrl } from '@/utils/randomResources'
* @returns 处理后的图片封面 * @returns 处理后的图片封面
*/ */
export function cover(cover: string) { export function cover(cover: string) {
const defaultCover = getRandomUrl()
if (!cover) {
return defaultCover
}
if (cover.trim()) { if (cover.trim()) {
return completeUrl(cover) return completeUrl(cover)
} }
return getRandomUrl() return defaultCover
}
/**
* 头像处理
* @param avatar 头像
* @returns 处理后的头像, 默认返回复古头像
*/
export function avatar(avatar: string) {
const defaultAvatar = getCrAvatar('retro')
if (!avatar) {
return defaultAvatar
}
if (avatar.trim()) {
return completeUrl(avatar)
}
return defaultAvatar
} }
/** /**
+27 -2
View File
@@ -1,5 +1,3 @@
import { sleep } from '@/utils/common'
export const randomImageUrl = { export const randomImageUrl = {
ycy: 'https://t.alcy.cc/ycy', ycy: 'https://t.alcy.cc/ycy',
pc: 'https://t.alcy.cc/pc', pc: 'https://t.alcy.cc/pc',
@@ -7,6 +5,7 @@ export const randomImageUrl = {
picsum: 'https://picsum.photos/800/600', picsum: 'https://picsum.photos/800/600',
bing: 'https://bing.img.run/rand_uhd.php', bing: 'https://bing.img.run/rand_uhd.php',
xsot: 'https://api.xsot.cn/bing?jump=true', xsot: 'https://api.xsot.cn/bing?jump=true',
boxmoe: 'https://api.boxmoe.com/random.php',
} }
type randomUrlKeys = keyof typeof randomImageUrl type randomUrlKeys = keyof typeof randomImageUrl
@@ -15,6 +14,12 @@ export const randomVideosUrl = {
acg: 'https://t.alcy.cc/acg', acg: 'https://t.alcy.cc/acg',
} }
/**
* 获取 Picsum 随机图片URL
* @param width 图片宽度 默认800
* @param height 图片高度 默认600
* @returns 随机图片URL
*/
export function getPicSumImages(width: number = 800, height?: number) { export function getPicSumImages(width: number = 800, height?: number) {
const t = `${Date.now()}+${Math.random()}` const t = `${Date.now()}+${Math.random()}`
if (!height) { if (!height) {
@@ -23,6 +28,26 @@ export function getPicSumImages(width: number = 800, height?: number) {
return `https://picsum.photos/${width}/${height}?t=${t}` return `https://picsum.photos/${width}/${height}?t=${t}`
} }
/**
* 获取 DiceURL
* @param avatar 头像名称 可选值:lorelei=简约卡通人脸, pixel-art=像素小人, adventurer=可爱大头, avataaars=极简线条
* @returns 头像URL
*/
export function getDiceBearAvatar(avatar: 'lorelei' | 'pixel-art' | 'adventurer' | 'avataaars', size?: number) {
const t = `${Date.now()}+${Math.random()}`
return `https://api.dicebear.com/9.x/${avatar}/svg?size=${size || 128}&random=${t}`
}
/**
* 获取 CravatarURL
* @param avatar 头像名称 可选值:monsterid=怪物头像, retro=复古头像, identicon=图标头像
* @returns 头像URL
*/
export function getCrAvatar(avatar: 'monsterid' | 'retro' | 'identicon', size?: number) {
const id = `${Date.now()}+${Math.random()}`
return `https://cravatar.cn/avatar/${id}?d=${avatar}&s=${size || 128}`
}
/** /**
* 获取随机图片URL * 获取随机图片URL
* @param type 图片类型 默认pc * @param type 图片类型 默认pc
+1 -1
View File
@@ -95,7 +95,7 @@ export default defineConfig({
theme: { theme: {
colors: { colors: {
/** 主题色,用法如: text-primary */ /** 主题色,用法如: text-primary */
primary: 'var(--wot-color-theme,#0957DE)', primary: '#1A1A1A',
page: '#F8F8F8', page: '#F8F8F8',
page2: '#F9FAFB', page2: '#F9FAFB',
page3: '#F8FAFC', page3: '#F8FAFC',