mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-07-27 04:20:43 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af87beebff | |||
| 9dc5aae164 | |||
| a510875d38 | |||
| 02c803f0c1 | |||
| a9e649e110 | |||
| 1ba71ff43f | |||
| 700eb10517 | |||
| 9c028bdc1b | |||
| 1ad951913e | |||
| e191eb8358 | |||
| a86baa9641 | |||
| c4fc9c930e | |||
| 01c929c4b5 | |||
| 2cc78dd329 | |||
| b56de87856 |
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
我们需要全局缓存一个点赞的列表,使用 metadata.name 做key,存储到store并且开启持久化存储,如果已经点过赞,直接显示已
|
||||
经点赞的样式。同时我们的点赞接口是不会返回内容的,所以再捕获异常的时候不要提示异常信息。
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
|
||||
@@ -35,7 +36,7 @@ export function createReply(data: CommentV1alpha1PublicApiCreateReply1Request) {
|
||||
* @returns 获取评论列表响应参数
|
||||
*/
|
||||
export function listComments(params: CommentV1alpha1PublicApiListComments1Request) {
|
||||
return http.Get<CommentWithReplyVoList>(`${EndpointBase}`, {
|
||||
return http.Get<IHaloListResponseBase<CommentWithReplyVo>>(`${EndpointBase}`, {
|
||||
params,
|
||||
meta: {
|
||||
isHalo: true,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { http } from '@/http/alova'
|
||||
|
||||
export interface IUniHaloConfig {
|
||||
// 应用信息配置
|
||||
app: {
|
||||
name: string
|
||||
}
|
||||
base: {
|
||||
// 博客域名
|
||||
domain: string
|
||||
// 博客token
|
||||
token: string
|
||||
// 是否为个人资质的小程序
|
||||
isIndividual: boolean
|
||||
}
|
||||
|
||||
@@ -821,7 +821,7 @@ export default {
|
||||
}
|
||||
}
|
||||
</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-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 v-if="loading || error || finished">
|
||||
<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">
|
||||
<wd-icon name="refresh" :size="14" /> 加载失败,点击刷新试试
|
||||
<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" /> 加载失败,点击重试
|
||||
</view>
|
||||
<view v-else-if="finished" class="text-xs text-gray-900">
|
||||
数据已全部加载
|
||||
|
||||
@@ -93,44 +93,52 @@ const textFooterClass = computed(() => {
|
||||
}
|
||||
return 'gap-x-2'
|
||||
})
|
||||
|
||||
function handleToPostDetail() {
|
||||
uni.navigateTo({
|
||||
url: `/subpkg-blog/post-detail/post-detail?metadataName=${props.post.metadata.name}`,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 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" />
|
||||
<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 class="box-border flex-1" :class="textWrapClass">
|
||||
<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 }}
|
||||
</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 }}
|
||||
</view>
|
||||
<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) }}
|
||||
</view>
|
||||
<template v-if="false">
|
||||
<view class="shrink-0 text-xs text-gray-600">
|
||||
·
|
||||
</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('·') }}
|
||||
</view>
|
||||
</template>
|
||||
<template v-if="props.type === 'topCover'">
|
||||
<view class="shrink-0 text-xs text-gray-600">
|
||||
·
|
||||
</view>
|
||||
<view class="text-[11px] text-gray-600">
|
||||
<view class="text-xs text-gray-600">
|
||||
点赞 {{ post.stats.upvote }} 次
|
||||
</view>
|
||||
</template>
|
||||
<view class="shrink-0 text-xs text-gray-600">
|
||||
·
|
||||
</view>
|
||||
<view class="line-clamp-1 text-[11px] text-gray-600">
|
||||
<view class="line-clamp-1 text-xs text-gray-600">
|
||||
浏览 {{ post.stats.visit }} 次
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useAppConfig } from '../useAppConfig'
|
||||
|
||||
const primaryColor = '#1a1a1a'
|
||||
|
||||
export const markdownConfig = {
|
||||
domain: useAppConfig().appBaseUrl.value,
|
||||
tagStyle: {
|
||||
@@ -9,8 +11,8 @@ border-collapse:collapse;
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: var(--routine);
|
||||
background: #f2f6fc;
|
||||
color: ${primaryColor};
|
||||
background: rgba(247, 247, 247, 1);
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 4px;
|
||||
`,
|
||||
@@ -26,68 +28,77 @@ border-bottom: 1px solid var(--classE);
|
||||
`,
|
||||
blockquote: `
|
||||
padding: 8px 15px;
|
||||
padding-bottom: 1px;
|
||||
color: #606266;
|
||||
background: #f2f6fc;
|
||||
border-left: 5px solid #50bfff;
|
||||
background: rgba(247, 247, 247, 1);
|
||||
border-left: 5px solid ${primaryColor};
|
||||
border-radius: 4px;
|
||||
line-height: 26px;
|
||||
margin-bottom: 18px;
|
||||
margin-top: 4px;
|
||||
`,
|
||||
ul: 'padding-left: 15px;line-height: 1.85;',
|
||||
ol: 'padding-left: 15px;line-height: 1.85;',
|
||||
ul: 'padding-left: 24px;line-height: 1.85;',
|
||||
ol: 'padding-left: 24px;line-height: 1.85;',
|
||||
li: 'margin-bottom: 12px;line-height: 1.85;',
|
||||
h1: `
|
||||
margin: 30px 0 20px;
|
||||
color: var(--main);
|
||||
color: ${primaryColor};
|
||||
line-height: 24px;
|
||||
position: relative;
|
||||
font-size:1.2em;
|
||||
font-weight: bold;
|
||||
`,
|
||||
h2: `
|
||||
color: var(--main);
|
||||
color: ${primaryColor};
|
||||
line-height: 24px;
|
||||
position: relative;
|
||||
margin: 22px 0 16px;
|
||||
font-size: 1.16em;
|
||||
font-weight: bold;
|
||||
`,
|
||||
h3: `
|
||||
color: var(--main);
|
||||
color: ${primaryColor};
|
||||
line-height: 24px;
|
||||
position: relative;
|
||||
margin: 26px 0 18px;
|
||||
font-size: 1.14em;
|
||||
font-weight: bold;
|
||||
`,
|
||||
h4: `
|
||||
color: var(--main);
|
||||
color: ${primaryColor};
|
||||
line-height: 24px;
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
font-size: 1.12em;
|
||||
font-weight: bold;
|
||||
`,
|
||||
h5: `
|
||||
color: var(--main);
|
||||
color: ${primaryColor};
|
||||
line-height: 24px;
|
||||
margin-bottom: 14px;
|
||||
position: relative;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
`,
|
||||
h6: `
|
||||
color: #303133;
|
||||
color: ${primaryColor};
|
||||
line-height: 24px;
|
||||
margin-bottom: 14px;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
`,
|
||||
p: `
|
||||
line-height: 1.65;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
`,
|
||||
code: ` `,
|
||||
strong: 'font-weight: 700;color: rgb(248, 57, 41);',
|
||||
strong: `font-weight: 700;color: ${primaryColor};`,
|
||||
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: '',
|
||||
emptyGif: '',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+379
-16
@@ -1,40 +1,403 @@
|
||||
<script lang="ts" setup>
|
||||
import { systemInfo } from '@/utils/systemInfo'
|
||||
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({
|
||||
style: {
|
||||
navigationBarTitleText: '登录',
|
||||
navigationStyle: 'custom',
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
},
|
||||
})
|
||||
|
||||
const tokenStore = useTokenStore()
|
||||
async function doLogin() {
|
||||
if (tokenStore.hasLogin) {
|
||||
uni.navigateBack()
|
||||
return
|
||||
const guestStore = useGuestStore()
|
||||
|
||||
// ---- Tab 切换 ----
|
||||
type AuthMode = 'login' | 'register'
|
||||
const activeMode = ref<AuthMode>('login')
|
||||
|
||||
function switchMode(mode: AuthMode) {
|
||||
activeMode.value = mode
|
||||
// 切换时清空错误提示
|
||||
clearErrors()
|
||||
}
|
||||
|
||||
// ---- 登录表单 ----
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
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: '小莫唐尼',
|
||||
password: '123456',
|
||||
username: loginForm.username.trim(),
|
||||
password: loginForm.password.trim(),
|
||||
})
|
||||
uni.navigateBack()
|
||||
handleLoginSuccess()
|
||||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
<view class="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 bg-page">
|
||||
<!-- 本页面是非MP的登录页,主要用于 h5 和 APP -->
|
||||
<view class="text-center">
|
||||
登录页
|
||||
<view
|
||||
class="box-border min-h-screen w-full flex flex-col items-center justify-center bg-page3 px-6"
|
||||
: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>
|
||||
<button class="mt-4 w-40 text-center" @click="doLogin">
|
||||
点击模拟登录
|
||||
</button>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,9 +1,10 @@
|
||||
<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 type { IPhoto, IPhotoGroup } from '@/api/halo-plugin/photo'
|
||||
import { completeUrl } from '@/utils/url'
|
||||
import random from '@/utils/base/random'
|
||||
|
||||
defineOptions({
|
||||
name: 'Gallery',
|
||||
@@ -11,7 +12,7 @@ defineOptions({
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '相册',
|
||||
navigationBarTitleText: '图库',
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
@@ -26,10 +27,17 @@ interface IGalleryGroup {
|
||||
photos: IPhoto[]
|
||||
}
|
||||
|
||||
const { setShareOptions } = useAppShare()
|
||||
|
||||
setShareOptions({
|
||||
title: '图库',
|
||||
path: '/pages/gallery/gallery',
|
||||
})
|
||||
|
||||
const groupBanner = reactive({
|
||||
current: 0,
|
||||
})
|
||||
const galleryStyle = ref('mix')
|
||||
const galleryStyle = ref('card')
|
||||
const showListPanel = reactive({
|
||||
show: false,
|
||||
metadataName: '',
|
||||
@@ -180,9 +188,9 @@ onPageScroll(() => { })
|
||||
</script>
|
||||
|
||||
<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="shrink-0">
|
||||
{{ galleryStyle }}:
|
||||
|
||||
+14
-16
@@ -1,5 +1,6 @@
|
||||
<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 { cover } from '@/utils/imageHelper'
|
||||
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'
|
||||
const postCardStyle = ref<PostCardStyle>('leftCover')
|
||||
|
||||
@@ -127,18 +134,9 @@ onPageScroll(() => { })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="bg-page3 min-h-screen">
|
||||
<view class="min-h-screen bg-page3">
|
||||
<!-- 顶部banner -->
|
||||
<view v-if="false" class="relative h-72 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>
|
||||
<view class="box-border w-full">
|
||||
<wd-swiper
|
||||
v-model:current="banner.current" :list="banner.items" indicator-position="right" autoplay
|
||||
:height="340"
|
||||
@@ -168,8 +166,8 @@ onPageScroll(() => { })
|
||||
</view>
|
||||
|
||||
<!-- 作者卡片 -->
|
||||
<view class="relative z-10 box-border px-4 -mt-12">
|
||||
<profile-card @click="showPostCardModeSelector = !showPostCardModeSelector" />
|
||||
<view class="relative z-10 box-border px-4 -mt-12" @click="showPostCardModeSelector = !showPostCardModeSelector">
|
||||
<profile-card />
|
||||
</view>
|
||||
|
||||
<!-- 测试 -->
|
||||
@@ -233,11 +231,11 @@ onPageScroll(() => { })
|
||||
<text class="text-sm text-gray-900 font-bold">最新笔记</text>
|
||||
</view>
|
||||
<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="暂无数据" />
|
||||
</view>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppShare } from '@/hooks/useShare'
|
||||
import { useRequest } from '@/hooks/useRequest'
|
||||
import { random } from '@/utils/base/random'
|
||||
import { queryLinkGroups, queryLinks } from '@/api/halo-plugin/link'
|
||||
@@ -26,9 +27,16 @@ definePage({
|
||||
},
|
||||
})
|
||||
|
||||
const { setShareOptions } = useAppShare()
|
||||
|
||||
const { config } = storeToRefs(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, error, data, run } = useRequest<IHaloListResponseBase<ILink>, IPaginationParams>(queryLinks, { loadingToast: false, loadingToastContent: '加载中...', loadingToastMask: true })
|
||||
|
||||
@@ -165,7 +173,7 @@ onLoad(async () => {
|
||||
</script>
|
||||
|
||||
<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-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
|
||||
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>
|
||||
@@ -258,7 +266,7 @@ onLoad(async () => {
|
||||
<button
|
||||
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!"
|
||||
@click="copy(link.spec.url, true)"
|
||||
@click="copy(link.spec.url, '链接已复制')"
|
||||
>
|
||||
<wd-icon name="copy" :size="14" /> 复制
|
||||
</button>
|
||||
|
||||
+173
-85
@@ -2,39 +2,92 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { LOGIN_PAGE } from '@/router/config'
|
||||
import { useUserStore } from '@/store'
|
||||
import { useGuestStore } from '@/store/guest'
|
||||
import { useTokenStore } from '@/store/token'
|
||||
import i18n, { t } from '@/locale/index'
|
||||
import { setTabbarItem } from '@/tabbar/i18n'
|
||||
import GuestInfoPanel from '@/components/post-detail/uh-guest-info-panel.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'Mine',
|
||||
})
|
||||
defineOptions({ name: 'Mine' })
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '我的',
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const tokenStore = useTokenStore()
|
||||
// 使用storeToRefs解构userInfo
|
||||
const guestStore = useGuestStore()
|
||||
|
||||
const { userInfo } = storeToRefs(userStore)
|
||||
|
||||
// 微信小程序下登录
|
||||
async function handleLogin() {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信登录
|
||||
await tokenStore.wxLogin()
|
||||
/** 是否已登录 */
|
||||
const hasLogin = computed(() => tokenStore.hasLogin)
|
||||
/** 是否已填写游客信息 */
|
||||
const hasGuestInfo = computed(() => guestStore.isComplete)
|
||||
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.navigateTo({
|
||||
url: `${LOGIN_PAGE}`,
|
||||
})
|
||||
// #endif
|
||||
/** 显示的头像 */
|
||||
const displayAvatar = computed(() => {
|
||||
if (hasLogin.value && userInfo.value.avatar) {
|
||||
return userInfo.value.avatar
|
||||
}
|
||||
return '/static/images/default-avatar.png'
|
||||
})
|
||||
|
||||
/** 显示的名称 */
|
||||
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() {
|
||||
@@ -43,93 +96,121 @@ function handleLogout() {
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 清空用户信息
|
||||
useTokenStore().logout()
|
||||
// 执行退出登录逻辑
|
||||
uni.showToast({
|
||||
title: '退出登录成功',
|
||||
icon: 'success',
|
||||
})
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序,去首页
|
||||
// uni.reLaunch({ url: '/pages/index/index' })
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
// 非微信小程序,去登录页
|
||||
// uni.navigateTo({ url: LOGIN_PAGE })
|
||||
// #endif
|
||||
tokenStore.logout()
|
||||
uni.showToast({ title: '已退出登录', icon: 'success' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const current = ref(uni.getLocale())
|
||||
const languages = [
|
||||
{
|
||||
value: 'zh-Hans',
|
||||
name: '中文',
|
||||
checked: 'true',
|
||||
},
|
||||
{
|
||||
value: 'en',
|
||||
name: '英文',
|
||||
},
|
||||
// ---- 功能导航 ----
|
||||
interface NavItem {
|
||||
icon: string
|
||||
label: string
|
||||
path?: string
|
||||
showArrow?: boolean
|
||||
}
|
||||
|
||||
const navList: NavItem[] = [
|
||||
{ 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) {
|
||||
// console.log(evt)
|
||||
current.value = evt.detail.value
|
||||
// 下面2句缺一不可!!!
|
||||
uni.setLocale(evt.detail.value)
|
||||
i18n.global.locale = evt.detail.value
|
||||
|
||||
// 底部tabbar需要重新设置一下
|
||||
setTabbarItem()
|
||||
// 本页的标题也需要重新设置一下
|
||||
uni.setNavigationBarTitle({
|
||||
title: t('i18n.title'),
|
||||
})
|
||||
function handleNavClick(item: NavItem) {
|
||||
if (item.path) {
|
||||
uni.navigateTo({ url: item.path })
|
||||
}
|
||||
else {
|
||||
uni.showToast({ title: '即将开放,敬请期待', icon: 'none' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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="mt-3 break-all px-3 text-center text-green-500">
|
||||
{{ userInfo.username ? '已登录' : '未登录' }}
|
||||
<view class="box-border min-h-screen w-full flex flex-col gap-y-4 bg-page3 px-4 pt-4">
|
||||
<!-- 用户信息卡片 -->
|
||||
<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 class="mt-3 break-all px-3">
|
||||
{{ JSON.stringify(userInfo, null, 2) }}
|
||||
</view>
|
||||
|
||||
<!-- 切换语言 -->
|
||||
<view class="mt-6 w-full flex flex-col items-center justify-center">
|
||||
<view class="mb-2 text-gray-900 font-bold">
|
||||
切换语言
|
||||
<text class="line-clamp-1 text-xs text-gray-500">{{ displaySubtitle }}</text>
|
||||
</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">
|
||||
<label
|
||||
v-for="item in languages"
|
||||
:key="item.value"
|
||||
class="flex items-center gap-x-2"
|
||||
<!-- 游客编辑入口 -->
|
||||
<view
|
||||
v-if="!hasLogin && hasGuestInfo"
|
||||
class="h-8 w-8 center shrink-0 rounded-full bg-gray-50"
|
||||
@click="handleEditGuest"
|
||||
>
|
||||
<view>
|
||||
<radio :value="item.value" :checked="item.value === current" />
|
||||
</view>
|
||||
<view>{{ item.name }}</view>
|
||||
</label>
|
||||
</radio-group>
|
||||
<wd-icon name="edit" color="#6B7280" :size="16" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mt-6">
|
||||
<view class="m-auto w-160px text-center">
|
||||
<button v-if="tokenStore.hasLogin" type="warn" class="w-full rounded-xl text-xs" @click="handleLogout">
|
||||
<!-- 游客信息快捷编辑入口(未登录且未填写游客信息时) -->
|
||||
<view
|
||||
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>
|
||||
<button v-else class="w-full rounded-xl bg-gray-900 py-2.5 text-xs text-white" @click="handleLogin">
|
||||
</view>
|
||||
<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>
|
||||
|
||||
@@ -137,5 +218,12 @@ function radioChange(evt) {
|
||||
<view class="w-full shrink-0">
|
||||
<uh-tabbar-page-placeholder />
|
||||
</view>
|
||||
|
||||
<!-- 游客信息编辑面板 -->
|
||||
<GuestInfoPanel
|
||||
v-if="showGuestPanel"
|
||||
@close="showGuestPanel = false"
|
||||
@saved="handleGuestSaved"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useAppShare } from '@/hooks/useShare'
|
||||
import { cover } from '@/utils/imageHelper'
|
||||
import { queryMoments } from '@/api/halo-plugin/moment'
|
||||
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 })
|
||||
|
||||
@@ -63,9 +71,9 @@ onPageScroll(() => { })
|
||||
</script>
|
||||
|
||||
<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="shrink-0">
|
||||
模式:【{{ useTimeline }}】
|
||||
|
||||
@@ -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 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 相同
|
||||
export const excludeLoginPathList = getAllPages('excludeLoginPath').map(page => page.path)
|
||||
|
||||
@@ -4,8 +4,12 @@ import type { IUniHaloConfig } from '@/api/halo-plugin/uni-halo'
|
||||
import { deepMerge } from '@/utils/base/deepMerge'
|
||||
|
||||
const defaultConfig: IUniHaloConfig = {
|
||||
app: {
|
||||
name: 'uni-halo',
|
||||
},
|
||||
base: {
|
||||
domain: import.meta.env.VITE_UNI_HALO_DOMAIN,
|
||||
token: import.meta.env.VITE_UNI_HALO_TOKEN,
|
||||
isIndividual: false,
|
||||
},
|
||||
home: {
|
||||
|
||||
@@ -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, // 手动管理持久化
|
||||
})
|
||||
@@ -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, // 手动管理持久化
|
||||
})
|
||||
@@ -11,11 +11,11 @@
|
||||
}
|
||||
|
||||
.uh-backdrop-blur {
|
||||
backdrop-filter: blur(4rpx);
|
||||
backdrop-filter: blur(2rpx);
|
||||
}
|
||||
|
||||
.uh-backdrop-blur-xs {
|
||||
backdrop-filter: blur(8rpx);
|
||||
backdrop-filter: blur(4rpx);
|
||||
}
|
||||
|
||||
.uh-backdrop-blur-sm {
|
||||
@@ -23,5 +23,5 @@
|
||||
}
|
||||
|
||||
.uh-backdrop-blur-md {
|
||||
backdrop-filter: blur(24rpx);
|
||||
backdrop-filter: blur(16rpx);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRequest } from '@/hooks/useRequest'
|
||||
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({
|
||||
name: 'PostDetail',
|
||||
@@ -8,14 +24,377 @@ defineOptions({
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '文章详情',
|
||||
navigationBarBackgroundColor: '#ffffff',
|
||||
navigationStyle: 'custom',
|
||||
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>
|
||||
|
||||
<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 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>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* 复制文本到剪贴板
|
||||
* @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({
|
||||
data: content,
|
||||
showToast: false,
|
||||
success: () => {
|
||||
if (showToast) {
|
||||
if (toastText) {
|
||||
uni.showToast({
|
||||
title: '复制成功',
|
||||
title: toastText,
|
||||
icon: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 处理后的图片封面
|
||||
*/
|
||||
export function cover(cover: string) {
|
||||
const defaultCover = getRandomUrl()
|
||||
if (!cover) {
|
||||
return defaultCover
|
||||
}
|
||||
if (cover.trim()) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { sleep } from '@/utils/common'
|
||||
|
||||
export const randomImageUrl = {
|
||||
ycy: 'https://t.alcy.cc/ycy',
|
||||
pc: 'https://t.alcy.cc/pc',
|
||||
@@ -7,6 +5,7 @@ export const randomImageUrl = {
|
||||
picsum: 'https://picsum.photos/800/600',
|
||||
bing: 'https://bing.img.run/rand_uhd.php',
|
||||
xsot: 'https://api.xsot.cn/bing?jump=true',
|
||||
boxmoe: 'https://api.boxmoe.com/random.php',
|
||||
}
|
||||
|
||||
type randomUrlKeys = keyof typeof randomImageUrl
|
||||
@@ -15,6 +14,12 @@ export const randomVideosUrl = {
|
||||
acg: 'https://t.alcy.cc/acg',
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Picsum 随机图片URL
|
||||
* @param width 图片宽度 默认800
|
||||
* @param height 图片高度 默认600
|
||||
* @returns 随机图片URL
|
||||
*/
|
||||
export function getPicSumImages(width: number = 800, height?: number) {
|
||||
const t = `${Date.now()}+${Math.random()}`
|
||||
if (!height) {
|
||||
@@ -23,6 +28,26 @@ export function getPicSumImages(width: number = 800, height?: number) {
|
||||
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
|
||||
* @param type 图片类型 默认pc
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ export default defineConfig({
|
||||
theme: {
|
||||
colors: {
|
||||
/** 主题色,用法如: text-primary */
|
||||
primary: 'var(--wot-color-theme,#0957DE)',
|
||||
primary: '#1A1A1A',
|
||||
page: '#F8F8F8',
|
||||
page2: '#F9FAFB',
|
||||
page3: '#F8FAFC',
|
||||
|
||||
Reference in New Issue
Block a user