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

feat: 新增博客token配置,完善文章详情页与markdown样式

1.  新增IUniHaloConfig的token配置项并关联环境变量
2.  调整文章跳转路径到subpkg分包目录
3.  重构markdown配置使用主题色替换硬编码值
4.  升级代码块样式为深色主题并添加圆角
5.  新增完整的文章详情页面实现
6.  删除冗余的markdown样式scss文件
7.  添加项目claude开发指南文档
This commit is contained in:
小莫唐尼
2026-06-15 16:36:23 +08:00
parent b56de87856
commit 2cc78dd329
9 changed files with 412 additions and 163 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
@@ -4,6 +4,8 @@ export interface IUniHaloConfig {
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:#111827;border-radius:12px}/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:#111827;border-radius:12px}.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>
+1 -1
View File
@@ -96,7 +96,7 @@ const textFooterClass = computed(() => {
function handleToPostDetail() { function handleToPostDetail() {
uni.navigateTo({ uni.navigateTo({
url: `/pages-blog/post-detail/post-detail?metadataName=${props.post.metadata.name}`, url: `/subpkg-blog/post-detail/post-detail?metadataName=${props.post.metadata.name}`,
}) })
} }
</script> </script>
+4 -2
View File
@@ -1,5 +1,7 @@
import { useAppConfig } from '../useAppConfig' import { useAppConfig } from '../useAppConfig'
const primaryColor = '#111827'
export const markdownConfig = { export const markdownConfig = {
domain: useAppConfig().appBaseUrl.value, domain: useAppConfig().appBaseUrl.value,
tagStyle: { tagStyle: {
@@ -28,7 +30,7 @@ border-bottom: 1px solid var(--classE);
padding: 8px 15px; padding: 8px 15px;
color: #606266; color: #606266;
background: #f2f6fc; background: #f2f6fc;
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;
@@ -87,7 +89,7 @@ font-size: 14px;
strong: 'font-weight: 700;color: rgb(248, 57, 41);', strong: 'font-weight: 700;color: rgb(248, 57, 41);',
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;border-radius: 6px;background-color:#FFFFFF;',
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;
}
}
}
}
+1
View File
@@ -6,6 +6,7 @@ import { deepMerge } from '@/utils/base/deepMerge'
const defaultConfig: IUniHaloConfig = { const defaultConfig: IUniHaloConfig = {
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: {
+284 -3
View File
@@ -1,5 +1,19 @@
<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 { upvote } from '@/api/halo-base/trackers'
import { completeUrl } from '@/utils/url'
import { timeFrom } from '@/utils/base/timeFrom'
import { formatNumberUnit } from '@/utils/base/digital'
import type { NavigationPostVo, PostV1alpha1PublicApiQueryPostByNameRequest, PostVo } from '@halo-dev/api-client'
import type { VoteRequest } from '@/api/halo-base/trackers'
import mpHtml from '@/components/mp-html/components/mp-html/mp-html.vue'
interface IPostDetailLoadOptions {
metadataName: string
}
defineOptions({ defineOptions({
name: 'PostDetail', name: 'PostDetail',
@@ -8,14 +22,281 @@ 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: true, loadingToastContent: '加载中...' })
const { data: navigation, run: fetchNavigation } = useRequest<NavigationPostVo, PostV1alpha1PublicApiQueryPostByNameRequest>(queryPostNavigationByName)
let postName = ''
// --- 点赞状态 ---
const isUpvoted = ref(false)
const upvoteCount = ref(0)
// --- 计算属性 ---
const coverUrl = computed(() => {
const raw = post.value?.spec?.cover
return raw ? completeUrl(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() {
if (isUpvoted.value)
return
try {
await upvote({
group: 'content.halo.run',
plural: 'posts',
name: postName,
} as VoteRequest)
isUpvoted.value = true
upvoteCount.value = (post.value?.stats?.upvote ?? 0) + 1
uni.showToast({ title: '点赞成功', icon: 'none' })
}
catch {
uni.showToast({ title: '点赞失败', icon: 'none' })
}
}
function handleShare() {
// #ifdef MP-WEIXIN
// 微信小程序中由页面右上角菜单处理分享
// #endif
// #ifdef H5
uni.setClipboardData({
data: post.value?.status?.permalink ?? '',
success: () => {
uni.showToast({ title: '链接已复制', icon: 'none' })
},
})
// #endif
}
function handleNavigatePost(name: string) {
postName = name
fetchPost({ name })
fetchNavigation({ name })
uni.pageScrollTo({ scrollTop: 0, duration: 300 })
}
async function loadData() {
await fetchPost({ name: postName })
fetchNavigation({ name: postName })
upvoteCount.value = post.value?.stats?.upvote ?? 0
}
// --- 生命周期 ---
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}`,
}
})
</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 z-100 box-border w-full flex items-center justify-between"
:style="{ paddingTop: 'calc(var(--status-bar-height, 44px) ' }">
<view class="box-border h-44px w-full flex items-center justify-between px-2">
<!-- 返回按钮 -->
<view class="h-8 w-9 center rounded-lg bg-gray-900 text-white" @click="handleBack">
<wd-icon name="arrow-left" color="#fff" :size="16" />
</view>
<view v-if="false" class="line-clamp-1 box-border pl-4 pr-6 text-white font-bold">
{{ post?.spec?.title ?? '笔记详情' }}
</view>
</view>
</view>
<!-- 顶部封面区域 -->
<view class="fixed left-0 right-0 top-0 z-10 h-52 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 class="uh-shadow-card relative z-20 box-border w-full flex flex-col gap-y-4 pt-52 -mt-8">
<!-- 主信息卡片 -->
<view v-if="htmlContent" class="box-border w-full flex flex-col gap-y-3 overflow-hidden rounded-3xl bg-white p-4">
<!-- 标题 -->
<text class="text-lg text-gray-900 font-bold leading-6">{{ post?.spec?.title ?? '加载中...' }}</text>
<!-- 分类标签 -->
<view v-if="categories.length > 0" class="flex flex-wrap items-center gap-2">
<view v-for="cat in categories" :key="cat.metadata.name"
class="rounded-full bg-gray-50 px-2.5 py-0.5 text-xs text-gray-600">
{{ cat.spec?.displayName }}
</view>
</view>
<!-- 元信息 -->
<view class="flex items-center gap-x-1">
<text class="text-[11px] text-gray-500">{{ publishTime }}</text>
<text class="text-[11px] text-gray-400">·</text>
<text class="text-[11px] text-gray-500">浏览 {{ stats.visit }}</text>
<text class="text-[11px] text-gray-400">·</text>
<text class="text-[11px] text-gray-500">评论 {{ stats.comment }}</text>
<text class="text-[11px] text-gray-400">·</text>
<text class="text-[11px] text-gray-500">点赞 {{ stats.upvote }}</text>
</view>
<!-- 分割线 -->
<view class="h-1px w-full bg-gray-50" />
<!-- 作者信息 -->
<view v-if="post?.owner" class="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>
<!-- 文章正文 -->
<mp-html :content="htmlContent" :tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle" :domain="markdownConfig.domain" lazy-load selectable />
</view>
<!-- 加载中占位 -->
<view v-else-if="loading" class="uh-shadow-xs box-border w-full center rounded-2xl bg-white py-16">
<wd-loading />
</view>
<!-- 标签区域 -->
<view v-if="tags.length > 0" class="uh-shadow-xs box-border w-full rounded-2xl bg-white p-4">
<view class="mb-3 text-sm text-gray-900 font-bold">
标签
</view>
<view class="flex flex-wrap gap-2">
<view v-for="tag in tags" :key="tag.metadata.name"
class="border border-gray-200 rounded-full px-3 py-1 text-xs text-gray-600">
# {{ tag.spec?.displayName }}
</view>
</view>
</view>
<!-- 互动操作栏 -->
<view class="uh-shadow-xs box-border w-full flex items-center justify-around rounded-2xl bg-white p-4">
<view class="flex flex-col items-center gap-y-1" @click="handleUpvote">
<view class="h-10 w-10 center rounded-full" :class="isUpvoted ? 'bg-red-50' : 'bg-gray-50'">
<wd-icon :name="isUpvoted ? 'thumb-up' : 'thumb-up'" :color="isUpvoted ? '#EF4444' : '#6B7280'"
:size="20" />
</view>
<text class="text-[11px]" :class="isUpvoted ? 'text-red-500' : 'text-gray-500'">
{{ stats.upvote }}
</text>
</view>
<view class="flex flex-col items-center gap-y-1" @click="handleShare">
<view class="h-10 w-10 center rounded-full bg-gray-50">
<wd-icon name="share" color="#6B7280" :size="20" />
</view>
<text class="text-[11px] text-gray-500">分享</text>
</view>
</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">
<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="completeUrl(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="completeUrl(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>
<!-- 底部安全区占位 -->
<view class="pb-safe">
<view class="h-4 w-full" />
</view>
</view> </view>
</template> </template>
<style scoped>
.uh-shadow-xs {
box-shadow: 0 0 24rpx rgba(0, 0, 0, 0.035);
}
.uh-shadow-card {
box-shadow: 0 16rpx 60rpx rgba(0, 0, 0, 0.04);
}
</style>