1. 小程序端
2. 视频优化2
This commit is contained in:
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
@@ -2,6 +2,5 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/blog-wot-uniapp" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
9
blog-wot-uniapp/.cursor/rules/Code-Standards.mdc
Normal file
9
blog-wot-uniapp/.cursor/rules/Code-Standards.mdc
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
|
||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行
|
||||
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
||||
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面
|
||||
12
blog-wot-uniapp/.cursor/rules/wot-ui.mdc
Normal file
12
blog-wot-uniapp/.cursor/rules/wot-ui.mdc
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: 小程序端优先使用 wot-ui 组件
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Wot UI 使用规范(blog-wot-uniapp)
|
||||
|
||||
1. UI 优先使用 `@wot-ui/ui` 已有组件(`wd-*`),例如底部菜单用 `wd-action-sheet`,对话框用 `wd-dialog`,表单用 `wd-input` / `wd-button`。
|
||||
2. 现有组件能力不足时:先二次封装(在 wot 外包一层交互与 art 样式,如 `ArtMenuItem`),再自研通用组件,放到主包 `components/` 或分包 `components/`。
|
||||
3. 禁止为同一交互再造一套原生弹层/列表(例如自写底部菜单替代 `wd-action-sheet`)。
|
||||
4. 抽屉类仍遵守工作区规则:小程序端用 `page-container`,防止用户意外退出页面。
|
||||
5. C 端视觉用 art token / `themeVars` 覆盖 wot 主题色与深色背景,而不是放弃 wot 组件。
|
||||
46
blog-wot-uniapp/.cursor/skill/wot-ui-v2/SKILL.md
Normal file
46
blog-wot-uniapp/.cursor/skill/wot-ui-v2/SKILL.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: wot-ui-v2
|
||||
description: '回答、生成、重构、排查 wot-ui v2 相关代码时使用。关键词:wot-ui、uni-app、Vue3、wd-、ConfigProvider、useToast、useDialog、Form、Popup、theme、llms-full。适用于组件选型、API 查询、示例页面生成、主题定制、常见坑排查。'
|
||||
argument-hint: '组件名、页面场景、问题描述或主题定制需求'
|
||||
---
|
||||
|
||||
# Wot UI V2 Skill
|
||||
|
||||
这个 skill 用于让 Agent 在处理 wot-ui v2 相关任务时,优先采用组件库既有能力、遵守 uni-app 场景限制,并结合本仓库提供的 `wot` CLI 查询离线知识。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 用户询问某个 `wd-*` 组件的基础用法、属性、事件、插槽或样式变量。
|
||||
- 需要生成或重构 `uni-app + Vue 3 + TypeScript` 的 wot-ui 页面或组件代码。
|
||||
- 需要在 `ConfigProvider`、主题变量、暗黑模式、国际化、反馈类 hooks、表单等场景下给出正确做法。
|
||||
- 需要排查文档中常见的 `Toast`、`Dialog`、`Popup`、`Tabs`、`Slider`、样式覆盖问题。
|
||||
- 用户只是泛化地提到主题定制,但还没有明确要求按“单文件主题 SCSS + App.vue 只 `@use`”的结构生成时。
|
||||
|
||||
## 推荐流程
|
||||
|
||||
1. 先用本仓库 CLI 或 MCP 工具查组件知识。
|
||||
2. 再根据项目实际安装方式决定导入路径与集成方式。
|
||||
3. 优先复用现成的 `wd-*` 组件、hooks、主题变量与组合模式,不要退化成原生标签堆砌。
|
||||
4. 如果问题涉及约束或坑位,再查阅 [参考知识](./references/overview.md)。
|
||||
5. 如果用户明确要求生成 `src/themes/styles/{主题名}.scss` 单文件主题,并把挂载逻辑收进主题文件,优先切换到 `create-wot-ui-theme` skill。
|
||||
|
||||
## 查询顺序
|
||||
|
||||
1. `wot list` 找组件名。
|
||||
2. `wot info <Component>` 看 props、events、slots、CSS 变量。
|
||||
3. `wot demo <Component>` 看 demo 名称或具体 demo 代码。
|
||||
4. `wot doc <Component>` 看完整 markdown 文档。
|
||||
5. `wot token <Component>` 看主题变量。
|
||||
|
||||
## 工作规则
|
||||
|
||||
- 默认把 wot-ui 视为 `uni-app + Vue 3 + TypeScript` 组件库。
|
||||
- 写页面时优先输出 `script setup` 风格。
|
||||
- 反馈类能力如 `useToast`、`useDialog`、`useNotify`、`useImagePreview`、`useVideoPreview`,除了 hook 调用外,通常还需要页面内显式声明对应组件实例。
|
||||
- 文档里经常出现 `@/uni_modules/wot-ui` 导入路径;如果用户项目采用 npm 安装,应切换成 `@wot-ui/ui`。
|
||||
- 主题定制优先走 `ConfigProvider` 和 CSS 变量,不优先建议深度覆盖内部类名。
|
||||
- 生成代码时尽量沿用组件库文档里的命名和交互模式,例如 `v-model:visible`、`before-confirm`、`confirm`、`change`、`custom-class`、`custom-style`。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Wot UI V2 概览](./references/overview.md)
|
||||
@@ -0,0 +1,85 @@
|
||||
# Wot UI V2 Overview
|
||||
|
||||
本文件根据 wot-ui v2 的 `llms-full.txt` 与本仓库现有 CLI 工作流提炼,目标是帮助 Agent 快速掌握适合生成代码与回答问题的高价值知识,而不是逐字复制官方文档。
|
||||
|
||||
## Product Positioning
|
||||
|
||||
- Wot UI v2 是面向 `uni-app` 的 `Vue 3 + TypeScript` 组件库。
|
||||
- 覆盖微信小程序、支付宝小程序、钉钉小程序、H5、APP 等平台。
|
||||
- 组件命名统一为 `wd-*`。
|
||||
- 组件库强调 AI 友好、主题定制、暗黑模式、国际化与跨端一致性。
|
||||
|
||||
## Installation And Integration
|
||||
|
||||
- npm 安装:`pnpm add @wot-ui/ui`
|
||||
- 使用前需要安装 `sass`。
|
||||
- `uni_modules` 安装模式天然支持 easycom 自动引入。
|
||||
- npm 安装模式通常需要配置 vite resolver 或 easycom。
|
||||
- CLI 项目在 npm 模式下可在 `tsconfig.json` 中加入 `@wot-ui/ui/global` 以增强全局组件类型提示。
|
||||
|
||||
## Import Rules
|
||||
|
||||
- npm 安装项目:组合式函数、类型和工具优先从 `@wot-ui/ui` 导入。
|
||||
- `uni_modules` 安装项目:文档中的 `@/uni_modules/wot-ui` 路径通常可直接使用。
|
||||
- 官方文档示例很多基于 `uni_modules` 路径,回答时要按用户项目实际安装方式转换。
|
||||
|
||||
## High Value Conventions
|
||||
|
||||
- 反馈类组件不能依赖全局挂载。页面内通常需要显式写出 `wd-toast`、`wd-dialog`、`wd-notify`、`wd-image-preview`、`wd-video-preview` 等实例。
|
||||
- `useToast`、`useDialog`、`useNotify`、`useQueue` 等 hooks 基于 `provide/inject`,应在 `setup` 中调用。
|
||||
- 页面内如果存在多个 `wd-dialog` 或 `wd-toast`,需要通过 `selector` 区分,否则可能出现实例冲突或重复弹出。
|
||||
- 自定义组件中如果要覆盖 wot-ui 内部样式,小程序环境通常需要把组件配置为 `styleIsolation: 'shared'`。
|
||||
- 在 `Popup`、`ActionSheet`、`DropDownItem` 等延迟渲染弹层里使用 `Slider`、`Tabs` 等依赖尺寸计算的组件时,打开后应调用实例方法重新初始化,例如 `initSlider()` 或 `updateLineStyle()`。
|
||||
|
||||
## Theme And Styling
|
||||
|
||||
- 主题定制优先走 CSS 变量。
|
||||
- Design Token 分三层:基础变量、语义变量、组件变量。
|
||||
- 局部或全局主题可通过 `wd-config-provider` 的 `theme` 和 `theme-vars` 控制。
|
||||
- 深色模式通过 `wd-config-provider theme="dark"` 开启。
|
||||
- 更推荐覆盖语义变量或组件变量,不推荐优先依赖深层 class 选择器覆盖。
|
||||
|
||||
## Common UI Patterns
|
||||
|
||||
- 表单场景优先组合 `wd-form`、`wd-form-item`、`wd-input`、`wd-textarea`、`wd-picker`、`wd-calendar`、`wd-select-picker`。
|
||||
- 弹层类场景优先使用 `wd-popup`、`wd-dialog`、`wd-action-sheet`、`wd-tooltip`、`wd-popover`。
|
||||
- 反馈类场景优先使用 `useToast`、`useDialog`、`useNotify`,不要直接手写临时弹层。
|
||||
- 列表和展示类场景优先考虑 `wd-cell`、`wd-card`、`wd-tag`、`wd-badge`、`wd-empty`、`wd-loadmore`、`wd-skeleton`。
|
||||
- 导航与布局类场景优先考虑 `wd-navbar`、`wd-tabs`、`wd-tabbar`、`wd-sidebar`、`wd-row`、`wd-col`、`wd-gap`。
|
||||
|
||||
## Interaction Patterns
|
||||
|
||||
- 大量组件采用 `v-model` 或 `v-model:visible` 控制状态。
|
||||
- 表单和选择类组件普遍提供 `confirm`、`change`、`close` 等事件。
|
||||
- 反馈组件常通过 hook 返回方法对象,例如 `toast.success()`、`dialog.confirm()`。
|
||||
- `Popover`、`Tooltip`、`SwipeAction` 等场景常与 `useQueue().closeOutside()` 配合,实现点击外部关闭。
|
||||
|
||||
## Component Selection Hints
|
||||
|
||||
- 需要主操作按钮时用 `wd-button`,不要先写原生 `button`。
|
||||
- 需要列表入口、设置页、表单容器时优先用 `wd-cell` 和 `wd-cell-group`。
|
||||
- 需要轻量提示时优先用 `useToast`;需要确认交互时优先用 `useDialog`。
|
||||
- 需要单选或多选弹层时优先用 `wd-select-picker`、`wd-picker`、`wd-cascader`。
|
||||
- 需要统一主题或暗黑模式时优先用 `wd-config-provider`。
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- `Toast`、`Dialog` 等函数式调用没有效果,先检查页面里是否声明了对应组件实例。
|
||||
- 同一个页面里多个无 `selector` 的反馈组件可能相互干扰。
|
||||
- npm 模式使用国际化时,开发态可能需要在 Vite 的 `optimizeDeps.exclude` 中排除 `@wot-ui/ui`。
|
||||
- 文档里的导入路径和项目实际安装方式不一致时,回答要主动修正。
|
||||
- 在弹层中直接渲染依赖尺寸测量的组件时,初始化时机往往比 API 本身更关键。
|
||||
|
||||
## AI Response Heuristics
|
||||
|
||||
- 回答基础用法时,优先给最小可运行模板,再补充常用 props。
|
||||
- 生成页面时,优先给完整的 `template + script setup + style` 结构。
|
||||
- 如果项目已使用 wot-ui,不要建议换用其他 UI 库。
|
||||
- 如果 wot-ui 已有现成组件,就不要用原生结构重复造轮子。
|
||||
- 如果用户只问某个组件,优先给该组件最常见 3 到 5 个用法,不要把整份文档全部展开。
|
||||
|
||||
## Repo-Specific Workflow
|
||||
|
||||
- 在本仓库中,优先使用 `wot list`、`wot info`、`wot doc`、`wot demo`、`wot token` 获取组件知识。
|
||||
- 当仓库数据与线上文档不一致时,以用户目标为准,并明确指出仓库离线数据可能需要重新提取。
|
||||
- 若要补数据或修提取逻辑,关注 `scripts/extract.ts`、`data/v2.json`、`src/data/*` 与相应命令实现。
|
||||
2
blog-wot-uniapp/.env.development
Normal file
2
blog-wot-uniapp/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE=http://127.0.0.1:8081/api
|
||||
VITE_UPLOAD_BASE=http://127.0.0.1:8081
|
||||
2
blog-wot-uniapp/.env.production
Normal file
2
blog-wot-uniapp/.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE=https://blog.nailaoyun.cn/api
|
||||
VITE_UPLOAD_BASE=https://blog.nailaoyun.cn
|
||||
21
blog-wot-uniapp/.gitignore
vendored
Normal file
21
blog-wot-uniapp/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
blog-wot-uniapp/.npmrc
Normal file
3
blog-wot-uniapp/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
shamefully-hoist=true
|
||||
15
blog-wot-uniapp/.vscode/extensions.json
vendored
Normal file
15
blog-wot-uniapp/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vue.volar",
|
||||
"mrmaoddxxaa.create-uniapp-view",
|
||||
"uni-helper.uni-helper-vscode",
|
||||
"uni-helper.uni-app-schemas-vscode",
|
||||
"uni-helper.uni-highlight-vscode",
|
||||
"uni-helper.uni-ui-snippets-vscode",
|
||||
"uni-helper.uni-app-snippets-vscode",
|
||||
"uni-helper.uni-cloud-snippets-vscode",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"johnsoncodehk.vscode-tsconfig-helper",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
37
blog-wot-uniapp/.vscode/settings.json
vendored
Normal file
37
blog-wot-uniapp/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"pages.json": "jsonc",
|
||||
"manifest.json": "jsonc"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"nvue",
|
||||
"uvue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"gql",
|
||||
"graphql",
|
||||
"astro",
|
||||
"svelte",
|
||||
"css",
|
||||
"less",
|
||||
"scss",
|
||||
"pcss",
|
||||
"postcss"
|
||||
]
|
||||
}
|
||||
39
blog-wot-uniapp/components.d.ts
vendored
Normal file
39
blog-wot-uniapp/components.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by vite-plugin-uni-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AmbientBackground: typeof import('./src/components/c-end/AmbientBackground.vue')['default']
|
||||
AppFloatingTabbar: typeof import('./src/components/layout/AppFloatingTabbar.vue')['default']
|
||||
AppNavbar: typeof import('./src/components/layout/AppNavbar.vue')['default']
|
||||
AppPageShell: typeof import('./src/components/layout/AppPageShell.vue')['default']
|
||||
ArtMenuGroup: typeof import('./src/components/c-end/ArtMenuGroup.vue')['default']
|
||||
ArtMenuItem: typeof import('./src/components/c-end/ArtMenuItem.vue')['default']
|
||||
ArtSearchBar: typeof import('./src/components/c-end/ArtSearchBar.vue')['default']
|
||||
PostCard: typeof import('./src/components/c-end/PostCard.vue')['default']
|
||||
ProfileHero: typeof import('./src/components/c-end/ProfileHero.vue')['default']
|
||||
WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['default']
|
||||
WdConfigProvider: typeof import('@wot-ui/ui/components/wd-config-provider/wd-config-provider.vue')['default']
|
||||
WdDialog: typeof import('@wot-ui/ui/components/wd-dialog/wd-dialog.vue')['default']
|
||||
WdEmpty: typeof import('@wot-ui/ui/components/wd-empty/wd-empty.vue')['default']
|
||||
WdForm: typeof import('@wot-ui/ui/components/wd-form/wd-form.vue')['default']
|
||||
WdIcon: typeof import('@wot-ui/ui/components/wd-icon/wd-icon.vue')['default']
|
||||
WdInput: typeof import('@wot-ui/ui/components/wd-input/wd-input.vue')['default']
|
||||
WdLoading: typeof import('@wot-ui/ui/components/wd-loading/wd-loading.vue')['default']
|
||||
WdLoadmore: typeof import('@wot-ui/ui/components/wd-loadmore/wd-loadmore.vue')['default']
|
||||
WdNavbar: typeof import('@wot-ui/ui/components/wd-navbar/wd-navbar.vue')['default']
|
||||
WdPicker: typeof import('@wot-ui/ui/components/wd-picker/wd-picker.vue')['default']
|
||||
WdPopup: typeof import('@wot-ui/ui/components/wd-popup/wd-popup.vue')['default']
|
||||
WdSwitch: typeof import('@wot-ui/ui/components/wd-switch/wd-switch.vue')['default']
|
||||
WdTab: typeof import('@wot-ui/ui/components/wd-tab/wd-tab.vue')['default']
|
||||
WdTabbar: typeof import('@wot-ui/ui/components/wd-tabbar/wd-tabbar.vue')['default']
|
||||
WdTabbarItem: typeof import('@wot-ui/ui/components/wd-tabbar-item/wd-tabbar-item.vue')['default']
|
||||
WdTabs: typeof import('@wot-ui/ui/components/wd-tabs/wd-tabs.vue')['default']
|
||||
WdTextarea: typeof import('@wot-ui/ui/components/wd-textarea/wd-textarea.vue')['default']
|
||||
WdToast: typeof import('@wot-ui/ui/components/wd-toast/wd-toast.vue')['default']
|
||||
}
|
||||
}
|
||||
203
blog-wot-uniapp/design-system/年糕崽崽/MASTER.md
Normal file
203
blog-wot-uniapp/design-system/年糕崽崽/MASTER.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Design System Master File
|
||||
|
||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||
> If that file exists, its rules **override** this Master file.
|
||||
> If not, strictly follow the rules below.
|
||||
|
||||
---
|
||||
|
||||
**Project:** 年糕崽崽
|
||||
**Generated:** 2026-07-27 14:56:05
|
||||
**Category:** Digital Products/Downloads
|
||||
|
||||
---
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Role | Hex | CSS Variable |
|
||||
|------|-----|--------------|
|
||||
| Primary | `#6366F1` | `--color-primary` |
|
||||
| Secondary | `#818CF8` | `--color-secondary` |
|
||||
| CTA/Accent | `#22C55E` | `--color-cta` |
|
||||
| Background | `#EEF2FF` | `--color-background` |
|
||||
| Text | `#312E81` | `--color-text` |
|
||||
|
||||
**Color Notes:** Digital indigo + buy green
|
||||
|
||||
### Typography
|
||||
|
||||
- **Heading Font:** Archivo
|
||||
- **Body Font:** Space Grotesk
|
||||
- **Mood:** minimal, portfolio, designer, creative, clean, artistic
|
||||
- **Google Fonts:** [Archivo + Space Grotesk](https://fonts.google.com/share?selection.family=Archivo:wght@300;400;500;600;700|Space+Grotesk:wght@300;400;500;600;700)
|
||||
|
||||
**CSS Import:**
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Archivo:wght@300;400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||
```
|
||||
|
||||
### Spacing Variables
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||
|
||||
### Shadow Depths
|
||||
|
||||
| Level | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||
|
||||
---
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: #22C55E;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #6366F1;
|
||||
border: 2px solid #6366F1;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: #EEF2FF;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #6366F1;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #6366F120;
|
||||
}
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
**Style:** Vibrant & Block-based
|
||||
|
||||
**Keywords:** Bold, energetic, playful, block layout, geometric shapes, high color contrast, duotone, modern, energetic
|
||||
|
||||
**Best For:** Startups, creative agencies, gaming, social media, youth-focused, entertainment, consumer
|
||||
|
||||
**Key Effects:** Large sections (48px+ gaps), animated patterns, bold hover (color shift), scroll-snap, large type (32px+), 200-300ms
|
||||
|
||||
### Page Pattern
|
||||
|
||||
**Pattern Name:** Portfolio Grid
|
||||
|
||||
- **Conversion Strategy:** hover overlay info, lightbox view, Visuals first. Filter by category. Fast loading essential.
|
||||
- **CTA Placement:** Project Card Hover + Footer Contact
|
||||
- **Section Order:** 1. Hero (Name/Role), 2. Project Grid (Masonry), 3. About/Philosophy, 4. Contact
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (Do NOT Use)
|
||||
|
||||
- ❌ No preview
|
||||
- ❌ Slow delivery
|
||||
|
||||
### Additional Forbidden Patterns
|
||||
|
||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering any UI code, verify:
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] `cursor-pointer` on all clickable elements
|
||||
- [ ] Hover states with smooth transitions (150-300ms)
|
||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] No horizontal scroll on mobile
|
||||
52
blog-wot-uniapp/design-system/年糕崽崽/pages/mine.md
Normal file
52
blog-wot-uniapp/design-system/年糕崽崽/pages/mine.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Mine Page Overrides
|
||||
|
||||
> **PROJECT:** 年糕崽崽
|
||||
> **Generated:** 2026-07-27 14:56:05
|
||||
> **Page Type:** Blog / Article
|
||||
|
||||
> ⚠️ **IMPORTANT:** Rules in this file **override** the Master file (`design-system/MASTER.md`).
|
||||
> Only deviations from the Master are documented here. For all other rules, refer to the Master.
|
||||
|
||||
---
|
||||
|
||||
## Page-Specific Rules
|
||||
|
||||
### Layout Overrides
|
||||
|
||||
- **Max Width:** 1200px (standard)
|
||||
- **Layout:** Full-width sections, centered content
|
||||
- **Sections:** 1. Hero (Name/Role), 2. Project Grid (Masonry), 3. About/Philosophy, 4. Contact
|
||||
|
||||
### Spacing Overrides
|
||||
|
||||
- No overrides — use Master spacing
|
||||
|
||||
### Typography Overrides
|
||||
|
||||
- No overrides — use Master typography
|
||||
|
||||
### Color Overrides
|
||||
|
||||
- **Strategy:** Neutral background (let work shine). Text: Black/White. Accent: Minimal.
|
||||
|
||||
### Component Overrides
|
||||
|
||||
- Avoid: Default keyboard for all inputs
|
||||
- Avoid: Desktop-first causing mobile issues
|
||||
- Avoid: Enable by default everywhere
|
||||
|
||||
---
|
||||
|
||||
## Page-Specific Components
|
||||
|
||||
- No unique components for this page
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
- Effects: Press deformation (scale + squish), bounce-back (cubic-bezier), material response, haptic-like feedback, spring physics
|
||||
- Forms: Use inputmode attribute
|
||||
- Responsive: Start with mobile styles then add breakpoints
|
||||
- Touch: Disable where not needed
|
||||
- CTA Placement: Project Card Hover + Footer Contact
|
||||
3
blog-wot-uniapp/eslint.config.js
Normal file
3
blog-wot-uniapp/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import uniHelper from '@uni-helper/eslint-config'
|
||||
|
||||
export default uniHelper()
|
||||
21
blog-wot-uniapp/index.html
Normal file
21
blog-wot-uniapp/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="static/logo.svg">
|
||||
<script>
|
||||
const coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)')
|
||||
|| CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
`<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0${
|
||||
coverSupport ? ', viewport-fit=cover' : ''}" />`)
|
||||
</script>
|
||||
<title></title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
94
blog-wot-uniapp/package.json
Normal file
94
blog-wot-uniapp/package.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "blog-wot-uniapp",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "uni",
|
||||
"dev:app": "uni -p app",
|
||||
"dev:app-android": "uni -p app-android",
|
||||
"dev:app-ios": "uni -p app-ios",
|
||||
"dev:custom": "uni -p",
|
||||
"dev:h5": "uni",
|
||||
"dev:h5:ssr": "uni --ssr",
|
||||
"dev:mp-alipay": "uni -p mp-alipay",
|
||||
"dev:mp-baidu": "uni -p mp-baidu",
|
||||
"dev:mp-jd": "uni -p mp-jd",
|
||||
"dev:mp-kuaishou": "uni -p mp-kuaishou",
|
||||
"dev:mp-lark": "uni -p mp-lark",
|
||||
"dev:mp-qq": "uni -p mp-qq",
|
||||
"dev:mp-toutiao": "uni -p mp-toutiao",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"dev:quickapp-webview": "uni -p quickapp-webview",
|
||||
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
|
||||
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
|
||||
"build": "uni build",
|
||||
"build:app": "uni build -p app",
|
||||
"build:app-android": "uni build -p app-android",
|
||||
"build:app-ios": "uni build -p app-ios",
|
||||
"build:custom": "uni build -p",
|
||||
"build:h5": "uni build",
|
||||
"build:h5:ssr": "uni build --ssr",
|
||||
"build:mp-alipay": "uni build -p mp-alipay",
|
||||
"build:mp-baidu": "uni build -p mp-baidu",
|
||||
"build:mp-jd": "uni build -p mp-jd",
|
||||
"build:mp-kuaishou": "uni build -p mp-kuaishou",
|
||||
"build:mp-lark": "uni build -p mp-lark",
|
||||
"build:mp-qq": "uni build -p mp-qq",
|
||||
"build:mp-toutiao": "uni build -p mp-toutiao",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-app-harmony": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-components": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-alipay": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-baidu": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-harmony": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-jd": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-kuaishou": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-lark": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-qq": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-toutiao": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
|
||||
"@wot-ui/ui": "^2.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^18.0.7",
|
||||
"mp-html": "^2.5.2",
|
||||
"pinia": "2.2.4",
|
||||
"vue": "3.4.21",
|
||||
"vue-i18n": "9.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@dcloudio/types": "^3.4.21",
|
||||
"@dcloudio/uni-automator": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4070620250821001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4070620250821001",
|
||||
"@mini-types/alipay": "^3.0.14",
|
||||
"@types/node": "^24.5.2",
|
||||
"@uni-helper/eslint-config": "^0.5.0",
|
||||
"@uni-helper/plugin-uni": "0.1.0",
|
||||
"@uni-helper/uni-types": "^1.0.0-alpha.6",
|
||||
"@uni-helper/vite-plugin-uni-components": "^0.2.1",
|
||||
"@vue/runtime-core": "3.4.21",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^9.36.0",
|
||||
"miniprogram-api-typings": "^4.1.0",
|
||||
"sass": "1.102.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "5.2.8",
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
10584
blog-wot-uniapp/pnpm-lock.yaml
generated
Normal file
10584
blog-wot-uniapp/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
blog-wot-uniapp/pnpm-workspace.yaml
Normal file
5
blog-wot-uniapp/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': set this to true or false
|
||||
core-js: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
vue-demi: set this to true or false
|
||||
70
blog-wot-uniapp/src/App.vue
Normal file
70
blog-wot-uniapp/src/App.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow } from '@dcloudio/uni-app'
|
||||
import { watch } from 'vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import { wxLoginApi } from '@/api'
|
||||
|
||||
const { hydrateAppMode, setLoginType } = useAppMode()
|
||||
const { scheduleExpiryCheck, isAuthenticated, sessionExpired, confirmSessionExpired, login, getUser } = useAuth()
|
||||
const { apply } = useTheme()
|
||||
const { load } = usePublicSettings()
|
||||
|
||||
watch(sessionExpired, (v) => {
|
||||
if (!v) return
|
||||
uni.showModal({
|
||||
title: '登录已过期',
|
||||
content: '请重新登录',
|
||||
showCancel: false,
|
||||
success: () => confirmSessionExpired(),
|
||||
})
|
||||
})
|
||||
|
||||
async function silentWxLogin() {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (isAuthenticated()) return
|
||||
try {
|
||||
const loginRes = await new Promise<UniApp.LoginRes>((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
if (!loginRes.code) return
|
||||
const res = await wxLoginApi(loginRes.code)
|
||||
login(res.token, { ...res.user }, res.expire)
|
||||
setLoginType('guest')
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[wx-login]', e)
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
onLaunch(async () => {
|
||||
apply()
|
||||
hydrateAppMode()
|
||||
await load()
|
||||
await silentWxLogin()
|
||||
if (isAuthenticated()) {
|
||||
scheduleExpiryCheck()
|
||||
const u = getUser()
|
||||
if (u.role) {
|
||||
// keep loginType consistent after silent login
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
apply()
|
||||
hydrateAppMode()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/styles/art-fonts.scss';
|
||||
@import '@/styles/art-tokens.scss';
|
||||
</style>
|
||||
411
blog-wot-uniapp/src/api/index.ts
Normal file
411
blog-wot-uniapp/src/api/index.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { get, post, put, del, patch, uploadFile } from '@/utils/request'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
bio?: string
|
||||
role: string
|
||||
isActive?: number
|
||||
phone?: string
|
||||
wechat?: string
|
||||
wechatQrcode?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: User
|
||||
expire: number
|
||||
}
|
||||
|
||||
export interface PaginationResponse<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: number | string
|
||||
title: string
|
||||
content?: string
|
||||
summary?: string
|
||||
excerpt?: string
|
||||
cover?: string
|
||||
date?: string
|
||||
categoryId?: number
|
||||
categoryName?: string
|
||||
columnId?: number
|
||||
columnName?: string
|
||||
userName?: string
|
||||
userAvatar?: string
|
||||
userId?: number
|
||||
readCount?: number
|
||||
isPublished?: number | boolean
|
||||
tags?: { id: number, name: string }[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface WorkLinks {
|
||||
live?: string
|
||||
demo?: string
|
||||
github?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface WorkTechGroup {
|
||||
category?: string
|
||||
items?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Work {
|
||||
id: string
|
||||
title: string
|
||||
category?: string
|
||||
year?: string
|
||||
heroImg?: string
|
||||
heroVideo?: string
|
||||
/** API 字段名为 desc */
|
||||
desc?: string
|
||||
description?: string
|
||||
videoUrl?: string
|
||||
videoId?: number | string
|
||||
techStack?: WorkTechGroup[]
|
||||
gallery?: string[]
|
||||
links?: WorkLinks | string
|
||||
next?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
id: number | string
|
||||
title?: string
|
||||
name?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
postCount?: number
|
||||
isActive?: number | boolean
|
||||
sortOrder?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Snippet {
|
||||
id: number | string
|
||||
title: string
|
||||
code?: string
|
||||
language?: string
|
||||
description?: string
|
||||
codeTypeId?: number
|
||||
postIds?: number[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface VideoItem {
|
||||
id: string
|
||||
title: string
|
||||
cover?: string
|
||||
poster?: string
|
||||
videoUrl?: string
|
||||
url?: string
|
||||
description?: string
|
||||
categoryId?: number
|
||||
categoryName?: string
|
||||
albumIds?: number[]
|
||||
isPublished?: number | boolean
|
||||
duration?: number
|
||||
createdAt?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface VideoAlbumNavContext {
|
||||
albumId: number
|
||||
albumName: string
|
||||
prevVideos: VideoItem[]
|
||||
nextVideos: VideoItem[]
|
||||
}
|
||||
|
||||
export interface VideoDetailItem extends VideoItem {
|
||||
albumContext?: VideoAlbumNavContext
|
||||
}
|
||||
|
||||
export interface VideoAlbum {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
cover?: string
|
||||
categoryId?: number
|
||||
categoryName?: string
|
||||
videoCount?: number
|
||||
videoIds?: number[]
|
||||
isActive?: number | boolean
|
||||
sortOrder?: number
|
||||
latestVideoUpdatedAt?: string
|
||||
previewVideos?: VideoItem[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface AboutExperience {
|
||||
year?: string
|
||||
role?: string
|
||||
company?: string
|
||||
}
|
||||
|
||||
export interface AboutProfile {
|
||||
id?: number
|
||||
name?: string
|
||||
avatar?: string
|
||||
location?: string
|
||||
bio?: string
|
||||
email?: string
|
||||
wechat?: string
|
||||
isPrimary?: number | boolean
|
||||
techStack?: string[]
|
||||
experiences?: AboutExperience[]
|
||||
role?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number
|
||||
originalName?: string
|
||||
fileName?: string
|
||||
fileUrl?: string
|
||||
url?: string
|
||||
fileType?: string
|
||||
fileSize?: number
|
||||
categoryId?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Inquiry {
|
||||
id: number
|
||||
name?: string
|
||||
company?: string
|
||||
contactMethod?: string
|
||||
contactValue?: string
|
||||
budget?: string
|
||||
description?: string
|
||||
status?: number
|
||||
createdAt?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ---- Auth ----
|
||||
export const loginApi = (username: string, password: string) =>
|
||||
post<LoginResponse>('/admin/login', { username, password })
|
||||
|
||||
export const wxLoginApi = (code: string) =>
|
||||
post<LoginResponse>('/auth/wx-login', { code })
|
||||
|
||||
/** 游客合并到管理员:需携带游客 JWT */
|
||||
export const mergeAccountApi = (username: string, password: string) =>
|
||||
post<LoginResponse>('/auth/merge-account', { username, password }, true)
|
||||
|
||||
export const getCurrentUser = () => get<User>('/admin/me', undefined, true)
|
||||
|
||||
/** 更新当前登录用户资料 */
|
||||
export const updateMe = (data: Partial<User>) => put<User>('/admin/me', data, true)
|
||||
|
||||
export const updatePassword = (oldPassword: string, newPassword: string) =>
|
||||
put('/admin/me/password', { oldPassword, newPassword }, true)
|
||||
|
||||
// ---- Public ----
|
||||
export const getPublicSettings = () => get<Record<string, string>>('/settings')
|
||||
|
||||
export const getPosts = (params: Record<string, unknown> = {}) =>
|
||||
get<PaginationResponse<Post> | Post[]>('/posts', params)
|
||||
|
||||
export const getPost = (id: string | number) => get<Post>(`/posts/${id}`)
|
||||
|
||||
export const getRecommendedPosts = (id: string | number, limit = 3) =>
|
||||
get<Post[]>(`/posts/${id}/recommendations`, { limit })
|
||||
|
||||
export const getHotSearches = (limit = 10) => get<{ keyword: string, count: number }[]>('/search/hot', { limit })
|
||||
|
||||
export const getColumns = () => get<Column[]>('/columns')
|
||||
export const getColumn = (id: string | number) => get<Column>(`/columns/${id}`)
|
||||
export const getColumnPosts = (id: string | number) => get<Post[]>(`/columns/${id}/posts`)
|
||||
|
||||
export const getWorks = () => get<Work[]>('/works')
|
||||
export const getWork = (id: string) => get<Work>(`/works/${id}`)
|
||||
|
||||
export const getSnippets = () => get<Snippet[]>('/snippets')
|
||||
export const getSnippet = (id: string | number) => get<Snippet>(`/snippets/${id}`)
|
||||
|
||||
export const getVideoCategories = () => get<unknown[]>('/video-categories')
|
||||
export const getVideoAlbums = (params: Record<string, unknown> = {}) =>
|
||||
get<VideoAlbum[]>('/video-albums', params)
|
||||
export const getVideoAlbum = (id: string | number) => get<VideoAlbum>(`/video-albums/${id}`)
|
||||
export const getVideos = (params: Record<string, unknown> = {}) => get<VideoItem[]>('/videos', params)
|
||||
export const getVideo = (id: string) => get<VideoDetailItem>(`/videos/${id}`)
|
||||
export const getAlbumVideos = (id: string | number) => get<VideoItem[]>(`/video-albums/${id}/videos`)
|
||||
|
||||
export const getAbout = () => get<AboutProfile | AboutProfile[]>('/about')
|
||||
export const getTestimonials = () => get<unknown[]>('/testimonials')
|
||||
export const getPartners = () => get<unknown[]>('/partners')
|
||||
export const getEmailSuffixes = () => get<string[]>('/email-suffixes')
|
||||
export const submitInquiry = (data: Record<string, unknown>) => post('/inquiries', data)
|
||||
|
||||
export const getCategories = () => get<{ id: number, name: string }[]>('/categories')
|
||||
export const getTags = () => get<{ id: number, name: string }[]>('/tags')
|
||||
|
||||
/** 仪表盘趋势点(后端 date + value;兼容旧 count 字段) */
|
||||
export interface TrendPoint {
|
||||
date: string
|
||||
value?: number
|
||||
count?: number
|
||||
yoy?: number
|
||||
mom?: number
|
||||
}
|
||||
|
||||
/** 读取趋势点数值:优先 value,兼容 SQL/旧接口的 count */
|
||||
export function trendPointValue(p?: TrendPoint | null): number {
|
||||
if (!p) return 0
|
||||
return Number(p.value ?? p.count ?? 0)
|
||||
}
|
||||
|
||||
/** 热门文章排行项:后端访问统计返回 count,兼容文章实体上的 readCount */
|
||||
export interface DashboardTopPost {
|
||||
title?: string
|
||||
article_id?: number
|
||||
count?: number
|
||||
readCount?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 概览结构化统计,对应 GetDashboardStats */
|
||||
export interface DashboardStats {
|
||||
posts?: number
|
||||
works?: number
|
||||
inquiryCount?: number
|
||||
postsTrend?: TrendPoint[]
|
||||
uvTrend?: TrendPoint[]
|
||||
topPosts?: DashboardTopPost[]
|
||||
operationTrend?: TrendPoint[]
|
||||
userRegions?: { region?: string, name?: string, count?: number, value?: number }[]
|
||||
}
|
||||
|
||||
// ---- Admin dashboard ----
|
||||
export const getDashboardStats = (params: Record<string, unknown> = {}) =>
|
||||
get<DashboardStats>('/admin/dashboard/stats', params, true)
|
||||
export const getRecentActivities = () => get<unknown[]>('/admin/dashboard/activities', undefined, true)
|
||||
export const getOperationLogs = (page = 1, pageSize = 10, filters: Record<string, unknown> = {}) =>
|
||||
get<PaginationResponse<unknown>>('/admin/operation-logs', { page, pageSize, ...filters }, true)
|
||||
export const getAccessLogs = (page = 1, pageSize = 10, filters: Record<string, unknown> = {}) =>
|
||||
get<PaginationResponse<unknown>>('/admin/access-logs', { page, pageSize, ...filters }, true)
|
||||
|
||||
// ---- Admin posts extras ----
|
||||
/** 切换文章发布状态 */
|
||||
export const patchPostStatus = (id: string | number, isPublished: number) =>
|
||||
patch(`/admin/posts/${id}/status`, { isPublished }, true)
|
||||
|
||||
export const patchPostRelations = (id: string | number, data: Record<string, unknown>) =>
|
||||
patch(`/admin/posts/${id}/relations`, data, true)
|
||||
|
||||
export const getPostHistory = (id: string | number) =>
|
||||
get<unknown[]>(`/admin/posts/${id}/history`, undefined, true)
|
||||
|
||||
/** 后台文章详情(含未发布),编辑表单专用;勿用公开 getPost */
|
||||
export const adminGetPost = (id: string | number) =>
|
||||
get<Post>(`/admin/posts/${id}`, undefined, true)
|
||||
|
||||
export const restorePostVersion = (id: string | number, version: string | number) =>
|
||||
post(`/admin/posts/${id}/history/${version}/restore`, {}, true)
|
||||
|
||||
export const getPostAccessLogs = (id: string | number, params: Record<string, unknown> = {}) =>
|
||||
get<PaginationResponse<unknown>>(`/admin/posts/${id}/access-logs`, params, true)
|
||||
|
||||
// ---- Admin inquiries ----
|
||||
export const updateInquiryStatus = (id: string | number, status: number) =>
|
||||
put(`/admin/inquiries/${id}/status`, { status }, true)
|
||||
|
||||
// ---- Admin columns posts ----
|
||||
export const addPostToColumn = (columnId: string | number, postId: string | number) =>
|
||||
post(`/admin/columns/${columnId}/posts`, { postId }, true)
|
||||
|
||||
export const removePostFromColumn = (columnId: string | number, postId: string | number) =>
|
||||
del(`/admin/columns/${columnId}/posts/${postId}`, true)
|
||||
|
||||
export const getAdminColumnPosts = (columnId: string | number) =>
|
||||
get<Post[]>(`/admin/columns/${columnId}/posts`, undefined, true).catch(() => getColumnPosts(columnId))
|
||||
|
||||
// ---- Admin video albums ----
|
||||
export const getAlbumVideoIds = (id: string | number) =>
|
||||
get<number[]>(`/admin/video-albums/${id}/video-ids`, undefined, true)
|
||||
|
||||
// ---- Admin attachments ----
|
||||
export const uploadAttachment = (filePath: string, formData: Record<string, string> = {}) =>
|
||||
uploadFile<Attachment>('/admin/attachments/upload', filePath, formData)
|
||||
|
||||
// ---- Admin settings ----
|
||||
export const getAdminSettings = () => get<unknown>('/admin/settings', undefined, true)
|
||||
export const batchUpdateSettings = (values: Record<string, string>) =>
|
||||
put('/admin/settings', values, true)
|
||||
|
||||
// ---- Admin OSS ----
|
||||
export const testOssConfig = (id: string | number) =>
|
||||
post<{ storageType: string, name: string }>(`/admin/oss-configs/${id}/test`, {}, true)
|
||||
|
||||
// ---- Admin generic CRUD helpers ----
|
||||
const RESOURCE_ALIAS: Record<string, string> = {
|
||||
logs: 'operation-logs',
|
||||
'access-logs': 'access-logs',
|
||||
about: 'about',
|
||||
settings: 'settings',
|
||||
}
|
||||
|
||||
function adminPath(resource: string, id?: string | number) {
|
||||
const name = RESOURCE_ALIAS[resource] || resource
|
||||
return id != null ? `/admin/${name}/${id}` : `/admin/${name}`
|
||||
}
|
||||
|
||||
export function adminList(resource: string, params: Record<string, unknown> = {}) {
|
||||
return get<unknown>(adminPath(resource), params, true)
|
||||
}
|
||||
|
||||
export function adminGet(resource: string, id: string | number) {
|
||||
return get<unknown>(adminPath(resource, id), undefined, true)
|
||||
}
|
||||
|
||||
export function adminCreate(resource: string, data: unknown) {
|
||||
return post(adminPath(resource), data, true)
|
||||
}
|
||||
|
||||
export function adminUpdate(resource: string, id: string | number, data: unknown) {
|
||||
return put(adminPath(resource, id), data, true)
|
||||
}
|
||||
|
||||
export function adminDelete(resource: string, id: string | number) {
|
||||
return del(adminPath(resource, id), true)
|
||||
}
|
||||
|
||||
/** 统一把列表接口结果规范成数组(兼容 list 包装与裸数组) */
|
||||
export function normalizeAdminList<T = Record<string, unknown>>(data: unknown): T[] {
|
||||
if (Array.isArray(data)) return data as T[]
|
||||
if (data && typeof data === 'object' && Array.isArray((data as { list?: unknown[] }).list)) {
|
||||
return (data as { list: T[] }).list
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** 统一分页结构 */
|
||||
export function normalizePagination<T = Record<string, unknown>>(
|
||||
data: unknown,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): PaginationResponse<T> {
|
||||
if (data && typeof data === 'object' && Array.isArray((data as PaginationResponse<T>).list)) {
|
||||
const p = data as PaginationResponse<T>
|
||||
return {
|
||||
list: p.list,
|
||||
total: Number(p.total ?? p.list.length),
|
||||
page: Number(p.page ?? page),
|
||||
pageSize: Number(p.pageSize ?? pageSize),
|
||||
}
|
||||
}
|
||||
const list = normalizeAdminList<T>(data)
|
||||
return { list, total: list.length, page, pageSize }
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
284
blog-wot-uniapp/src/components/c-end/AmbientBackground.vue
Normal file
284
blog-wot-uniapp/src/components/c-end/AmbientBackground.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* C 端氛围层:暗色星空 + 偶发流星;浅色金色光斑漂移。
|
||||
* 粒子数约为 PC StarBackground 的 1/3,挂在 AppPageShell 底层。
|
||||
* 使用旧版 canvas 上下文以保证各端兼容;不可点击(pointer-events: none)。
|
||||
*/
|
||||
import { getCurrentInstance, nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { onHide, onShow } from '@dcloudio/uni-app'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const canvasId = `ambient-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
interface Star {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
a: number
|
||||
out: boolean
|
||||
sp: number
|
||||
}
|
||||
interface Meteor {
|
||||
x: number
|
||||
y: number
|
||||
len: number
|
||||
sp: number
|
||||
a: number
|
||||
}
|
||||
interface Orb {
|
||||
bx: number
|
||||
by: number
|
||||
r: number
|
||||
ampX: number
|
||||
ampY: number
|
||||
phX: number
|
||||
phY: number
|
||||
sp: number
|
||||
color: string
|
||||
}
|
||||
interface Dust {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
a: number
|
||||
out: boolean
|
||||
sp: number
|
||||
base: number
|
||||
}
|
||||
|
||||
let ctx: UniApp.CanvasContext | null = null
|
||||
let width = 0
|
||||
let height = 0
|
||||
let running = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let time = 0
|
||||
const stars: Star[] = []
|
||||
const meteors: Meteor[] = []
|
||||
const orbs: Orb[] = []
|
||||
const dust: Dust[] = []
|
||||
|
||||
const clearPools = () => {
|
||||
stars.length = 0
|
||||
meteors.length = 0
|
||||
orbs.length = 0
|
||||
dust.length = 0
|
||||
}
|
||||
|
||||
const initDark = () => {
|
||||
clearPools()
|
||||
for (let i = 0; i < 55; i++) {
|
||||
stars.push({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
r: Math.random() * 1.4 + 0.3,
|
||||
a: Math.random(),
|
||||
out: Math.random() > 0.5,
|
||||
sp: Math.random() * 0.02 + 0.005,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const initLight = () => {
|
||||
clearPools()
|
||||
const palette = [
|
||||
'rgba(212,179,131,0.18)',
|
||||
'rgba(196,150,90,0.14)',
|
||||
'rgba(232,200,150,0.16)',
|
||||
]
|
||||
const places = [
|
||||
{ bx: 0.18, by: 0.22 },
|
||||
{ bx: 0.8, by: 0.38 },
|
||||
{ bx: 0.42, by: 0.78 },
|
||||
]
|
||||
places.forEach((p, i) => {
|
||||
orbs.push({
|
||||
bx: p.bx * width,
|
||||
by: p.by * height,
|
||||
r: Math.min(width, height) * (0.22 + Math.random() * 0.1),
|
||||
ampX: 28 + Math.random() * 40,
|
||||
ampY: 22 + Math.random() * 36,
|
||||
phX: Math.random() * Math.PI * 2,
|
||||
phY: Math.random() * Math.PI * 2,
|
||||
sp: 0.0003 + Math.random() * 0.0002,
|
||||
color: palette[i % palette.length],
|
||||
})
|
||||
})
|
||||
for (let i = 0; i < 28; i++) {
|
||||
const base = Math.random() * 0.3 + 0.12
|
||||
dust.push({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
r: Math.random() * 1.6 + 0.4,
|
||||
a: base,
|
||||
out: Math.random() > 0.5,
|
||||
sp: Math.random() * 0.008 + 0.003,
|
||||
base,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const initTheme = () => {
|
||||
if (isDark.value) initDark()
|
||||
else initLight()
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
if (!ctx || !running) return
|
||||
time += 1
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
if (isDark.value) {
|
||||
stars.forEach((s) => {
|
||||
if (s.out) {
|
||||
s.a -= s.sp
|
||||
if (s.a <= 0.1) s.out = false
|
||||
}
|
||||
else {
|
||||
s.a += s.sp
|
||||
if (s.a >= 1) s.out = true
|
||||
}
|
||||
ctx!.beginPath()
|
||||
ctx!.arc(s.x, s.y, s.r, 0, Math.PI * 2)
|
||||
ctx!.setFillStyle(`rgba(255,255,255,${Math.max(0.08, s.a)})`)
|
||||
ctx!.fill()
|
||||
})
|
||||
if (Math.random() < 0.008) {
|
||||
meteors.push({
|
||||
x: Math.random() * width,
|
||||
y: 0,
|
||||
len: Math.random() * 60 + 24,
|
||||
sp: Math.random() * 8 + 5,
|
||||
a: 1,
|
||||
})
|
||||
}
|
||||
for (let i = meteors.length - 1; i >= 0; i--) {
|
||||
const m = meteors[i]
|
||||
const ex = m.x - m.len * 0.7
|
||||
const ey = m.y - m.len * 0.7
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(m.x, m.y)
|
||||
ctx.lineTo(ex, ey)
|
||||
ctx.setStrokeStyle(`rgba(255,255,255,${m.a})`)
|
||||
ctx.setLineWidth(1.2)
|
||||
ctx.stroke()
|
||||
m.x += m.sp * 0.7
|
||||
m.y += m.sp * 0.7
|
||||
m.a -= 0.012
|
||||
if (m.a <= 0 || m.y > height) meteors.splice(i, 1)
|
||||
}
|
||||
}
|
||||
else {
|
||||
orbs.forEach((o) => {
|
||||
const x = o.bx + Math.sin(time * o.sp + o.phX) * o.ampX
|
||||
const y = o.by + Math.cos(time * o.sp + o.phY) * o.ampY
|
||||
const g = ctx!.createCircularGradient(x, y, o.r)
|
||||
g.addColorStop(0, o.color)
|
||||
g.addColorStop(1, 'rgba(212,179,131,0)')
|
||||
ctx!.beginPath()
|
||||
ctx!.arc(x, y, o.r, 0, Math.PI * 2)
|
||||
ctx!.setFillStyle(g)
|
||||
ctx!.fill()
|
||||
})
|
||||
dust.forEach((d) => {
|
||||
if (d.out) {
|
||||
d.a -= d.sp
|
||||
if (d.a <= 0.05) d.out = false
|
||||
}
|
||||
else {
|
||||
d.a += d.sp
|
||||
if (d.a >= d.base) d.out = true
|
||||
}
|
||||
ctx!.beginPath()
|
||||
ctx!.arc(d.x, d.y, d.r, 0, Math.PI * 2)
|
||||
ctx!.setFillStyle(`rgba(196,150,90,${d.a})`)
|
||||
ctx!.fill()
|
||||
})
|
||||
}
|
||||
|
||||
ctx.draw()
|
||||
timer = setTimeout(() => {
|
||||
if (running) draw()
|
||||
}, 48)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
running = false
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
stop()
|
||||
running = true
|
||||
draw()
|
||||
}
|
||||
|
||||
const setup = () => {
|
||||
const inst = getCurrentInstance()
|
||||
uni.getSystemInfo({
|
||||
success: (info) => {
|
||||
width = info.windowWidth
|
||||
height = info.windowHeight
|
||||
nextTick(() => {
|
||||
ctx = uni.createCanvasContext(canvasId, inst?.proxy as never)
|
||||
initTheme()
|
||||
start()
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(setup)
|
||||
onShow(() => {
|
||||
if (ctx) start()
|
||||
})
|
||||
onHide(stop)
|
||||
onBeforeUnmount(stop)
|
||||
|
||||
watch(isDark, () => {
|
||||
initTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="ambient" aria-hidden="true">
|
||||
<canvas
|
||||
:canvas-id="canvasId"
|
||||
class="ambient-canvas"
|
||||
:style="{ width: '100%', height: '100%' }"
|
||||
disable-scroll
|
||||
/>
|
||||
<view class="noise" :class="isDark ? 'noise--dark' : 'noise--light'" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ambient {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ambient-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.noise {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
/* 轻量噪点:用半透明叠层近似胶片感,避免额外图片体积 */
|
||||
background-image: radial-gradient(rgba(127, 127, 127, 0.35) 0.5px, transparent 0.5px);
|
||||
background-size: 4px 4px;
|
||||
}
|
||||
.noise--light { opacity: 0.045; mix-blend-mode: multiply; }
|
||||
.noise--dark { opacity: 0.06; mix-blend-mode: soft-light; }
|
||||
</style>
|
||||
64
blog-wot-uniapp/src/components/c-end/ArtMenuGroup.vue
Normal file
64
blog-wot-uniapp/src/components/c-end/ArtMenuGroup.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* C 端菜单分组:分组标题 + 内容槽位。
|
||||
* layout=grid 时子项宫格排列;通过 provide 告知 ArtMenuItem 切换样式。
|
||||
*/
|
||||
import { computed, provide, toRef } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
eyebrow?: string
|
||||
/** list 纵向列表;grid 宫格(默认 list 兼容其它页) */
|
||||
layout?: 'list' | 'grid'
|
||||
}>(), {
|
||||
title: '',
|
||||
eyebrow: '',
|
||||
layout: 'list',
|
||||
})
|
||||
|
||||
provide('artMenuLayout', toRef(props, 'layout'))
|
||||
|
||||
const bodyClass = computed(() => (
|
||||
props.layout === 'grid' ? 'group-body group-body--grid' : 'group-body'
|
||||
))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="art-menu-group">
|
||||
<view v-if="title || eyebrow" class="group-head">
|
||||
<text v-if="eyebrow" class="hero-eyebrow">{{ eyebrow }}</text>
|
||||
<text v-if="title" class="group-title">{{ title }}</text>
|
||||
</view>
|
||||
<view :class="bodyClass">
|
||||
<slot />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.art-menu-group {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
.group-head {
|
||||
margin-bottom: 20rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
.group-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: rgb(var(--art-text));
|
||||
font-family: 'Playfair Display', 'Noto Serif SC', serif;
|
||||
font-style: italic;
|
||||
}
|
||||
.group-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
/* 三列宫格;窄屏仍可读,卡片自带最小高度 */
|
||||
.group-body--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
</style>
|
||||
136
blog-wot-uniapp/src/components/c-end/ArtMenuItem.vue
Normal file
136
blog-wot-uniapp/src/components/c-end/ArtMenuItem.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* C 端菜单行:图标 + 标题/副标题 + 右侧值或箭头。
|
||||
* 使用 art-card 与按压缩放,替代 wd-cell 的白底样式。
|
||||
* 若父级 ArtMenuGroup 为 grid,改为上图标下标题的宫格单元。
|
||||
*/
|
||||
import { computed, inject, type Ref } from 'vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
title: string
|
||||
label?: string
|
||||
value?: string
|
||||
icon?: string
|
||||
/** 是否显示右侧箭头(可点击入口);宫格模式下隐藏 */
|
||||
isLink?: boolean
|
||||
}>(), {
|
||||
label: '',
|
||||
value: '',
|
||||
icon: '',
|
||||
isLink: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ click: [] }>()
|
||||
|
||||
/** 从分组注入布局;无分组时保持列表 */
|
||||
const injected = inject<Ref<'list' | 'grid'> | undefined>('artMenuLayout', undefined)
|
||||
const isGrid = computed(() => injected?.value === 'grid')
|
||||
|
||||
/** 点击整行时向外抛出,由页面决定跳转或弹层 */
|
||||
const onTap = () => {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="art-menu-item art-card art-card-press"
|
||||
:class="{ 'art-menu-item--grid': isGrid }"
|
||||
@click="onTap"
|
||||
>
|
||||
<view v-if="icon" class="icon-wrap">
|
||||
<wd-icon :name="icon" :size="isGrid ? '26px' : '22px'" color="rgb(212,179,131)" />
|
||||
</view>
|
||||
<view class="content">
|
||||
<text class="title">{{ title }}</text>
|
||||
<!-- 宫格空间有限,不展示长副标题 -->
|
||||
<text v-if="label && !isGrid" class="label text-art-muted">{{ label }}</text>
|
||||
<text v-if="value && isGrid" class="value-grid text-art-muted">{{ value }}</text>
|
||||
</view>
|
||||
<text v-if="value && !isGrid" class="value text-art-muted">{{ value }}</text>
|
||||
<wd-icon
|
||||
v-if="isLink && !isGrid"
|
||||
name="arrow-right"
|
||||
size="16px"
|
||||
color="rgb(136,136,136)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.art-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 28rpx 28rpx;
|
||||
min-height: 104rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.art-menu-item--grid {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
padding: 28rpx 12rpx 24rpx;
|
||||
min-height: 168rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.icon-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(212, 179, 131, 0.12);
|
||||
}
|
||||
.art-menu-item--grid .icon-wrap {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
.art-menu-item--grid .content {
|
||||
flex: none;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.title {
|
||||
font-size: 30rpx;
|
||||
color: rgb(var(--art-text));
|
||||
line-height: 1.35;
|
||||
}
|
||||
.art-menu-item--grid .title {
|
||||
font-size: 26rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.label {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.value {
|
||||
flex-shrink: 0;
|
||||
font-size: 26rpx;
|
||||
max-width: 200rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.value-grid {
|
||||
font-size: 22rpx;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
94
blog-wot-uniapp/src/components/c-end/ArtSearchBar.vue
Normal file
94
blog-wot-uniapp/src/components/c-end/ArtSearchBar.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* C 端搜索条:圆角 + accent focus,暗色可读;不依赖 wd-search 白底。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
placeholder: '搜索',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string]
|
||||
search: [string]
|
||||
clear: []
|
||||
focus: []
|
||||
blur: []
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const focused = ref(false)
|
||||
|
||||
const inputStyle = computed(() => {
|
||||
const dark = isDark.value
|
||||
const border = focused.value
|
||||
? '1px solid rgb(212,179,131)'
|
||||
: dark
|
||||
? '1px solid rgba(255,255,255,0.1)'
|
||||
: '1px solid rgba(0,0,0,0.08)'
|
||||
const bg = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
|
||||
const color = dark ? '#ececec' : '#171717'
|
||||
return `background:${bg};border:${border};color:${color};`
|
||||
})
|
||||
|
||||
const onInput = (e: { detail?: { value?: string } }) => {
|
||||
emit('update:modelValue', e.detail?.value ?? '')
|
||||
}
|
||||
|
||||
const onConfirm = () => emit('search', props.modelValue)
|
||||
|
||||
const onClear = () => {
|
||||
emit('update:modelValue', '')
|
||||
emit('clear')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="bar" :class="{ 'bar--focus': focused }" :style="inputStyle">
|
||||
<wd-icon name="search-line" size="18px" color="rgb(136,136,136)" />
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
confirm-type="search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
placeholder-class="ph"
|
||||
@input="onInput"
|
||||
@confirm="onConfirm"
|
||||
@focus="focused = true; emit('focus')"
|
||||
@blur="focused = false; emit('blur')"
|
||||
>
|
||||
<view v-if="modelValue" class="clear" @click.stop="onClear">
|
||||
<wd-icon name="close" size="16px" color="rgb(136,136,136)" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
height: 44rpx;
|
||||
line-height: 44rpx;
|
||||
background: transparent;
|
||||
}
|
||||
.ph { color: rgb(136, 136, 136); }
|
||||
.clear {
|
||||
padding: 4rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
97
blog-wot-uniapp/src/components/c-end/PostCard.vue
Normal file
97
blog-wot-uniapp/src/components/c-end/PostCard.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* C 端文章卡片:对齐 PC Blog 列表信息结构(分类/日期、标题、摘要、作者头像、标签、CTA)。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import type { Post } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const props = defineProps<{
|
||||
post: Post
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const DEFAULT_AVATAR = '/static/default-avatar.svg'
|
||||
|
||||
const avatarSrc = computed(() => {
|
||||
const raw = props.post.userAvatar
|
||||
if (!raw) return DEFAULT_AVATAR
|
||||
return resolveMediaUrl(String(raw)) || DEFAULT_AVATAR
|
||||
})
|
||||
|
||||
const summaryText = computed(() =>
|
||||
String(props.post.summary || props.post.excerpt || '').trim(),
|
||||
)
|
||||
|
||||
const metaLine = computed(() => {
|
||||
const cat = props.post.categoryName || ''
|
||||
const date = props.post.date || ''
|
||||
if (cat && date) return `${cat} · ${date}`
|
||||
return cat || date || ''
|
||||
})
|
||||
|
||||
const tagNames = computed(() => {
|
||||
const tags = props.post.tags
|
||||
if (!Array.isArray(tags)) return [] as string[]
|
||||
return tags.map(t => (typeof t === 'string' ? t : t?.name)).filter(Boolean) as string[]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="art-card art-card-press card" @click="emit('click')">
|
||||
<text v-if="metaLine" class="font-mono-label text-art-muted">{{ metaLine }}</text>
|
||||
<text class="title">{{ post.title }}</text>
|
||||
<text v-if="summaryText" class="summary text-art-muted">{{ summaryText }}</text>
|
||||
<view v-if="post.userName" class="author">
|
||||
<image class="avatar" :src="avatarSrc" mode="aspectFill" />
|
||||
<text class="name">{{ post.userName }}</text>
|
||||
</view>
|
||||
<view v-if="tagNames.length" class="tags">
|
||||
<text v-for="t in tagNames" :key="t" class="tag text-art-muted">#{{ t }}</text>
|
||||
</view>
|
||||
<text class="cta text-art-accent">阅读全文 →</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card {
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.title {
|
||||
font-size: 36rpx;
|
||||
color: rgb(var(--art-text));
|
||||
font-family: 'Playfair Display', serif;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.summary {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
.name { font-size: 24rpx; color: rgb(var(--art-text)); }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.tag { font-size: 22rpx; font-family: 'JetBrains Mono', monospace; }
|
||||
.cta { font-size: 24rpx; margin-top: 4rpx; }
|
||||
</style>
|
||||
111
blog-wot-uniapp/src/components/c-end/ProfileHero.vue
Normal file
111
blog-wot-uniapp/src/components/c-end/ProfileHero.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* C 端个人页头图:大头像 + 昵称 + 状态文案。
|
||||
* 用 art 金色光环与渐变底,避免 wd-cell 式后台感,统一深浅色 token。
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
avatarUrl: string
|
||||
name: string
|
||||
/** 状态短文案,如「登录后可同步更多内容」 */
|
||||
subtitle?: string
|
||||
/** 弱化的状态 pill,如「访客」 */
|
||||
badge?: string
|
||||
}>(), {
|
||||
subtitle: '',
|
||||
badge: '',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="profile-hero animate-reveal">
|
||||
<view class="hero-glow" />
|
||||
<view class="hero-row">
|
||||
<view class="avatar-ring">
|
||||
<image class="avatar" :src="avatarUrl" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="meta">
|
||||
<text class="name font-serif-italic">{{ name }}</text>
|
||||
<text v-if="subtitle" class="subtitle text-art-muted">{{ subtitle }}</text>
|
||||
<view v-if="badge" class="badge">
|
||||
<text class="badge-text font-mono-label text-art-accent">{{ badge }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-hero {
|
||||
position: relative;
|
||||
margin-bottom: 48rpx;
|
||||
padding: 40rpx 32rpx 48rpx;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(var(--art-accent), 0.12) 0%,
|
||||
rgb(var(--art-surface)) 48%,
|
||||
rgba(var(--art-accent), 0.06) 100%
|
||||
);
|
||||
border: 1px solid var(--art-card-border);
|
||||
}
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
top: -80rpx;
|
||||
right: -40rpx;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(212, 179, 131, 0.22) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32rpx;
|
||||
}
|
||||
.avatar-ring {
|
||||
flex-shrink: 0;
|
||||
padding: 6rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgb(var(--art-accent)), rgba(212, 179, 131, 0.35));
|
||||
box-shadow: 0 12rpx 40rpx -8rpx rgba(212, 179, 131, 0.35);
|
||||
}
|
||||
.avatar {
|
||||
display: block;
|
||||
width: 144rpx;
|
||||
height: 144rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-bg));
|
||||
border: 4rpx solid rgb(var(--art-bg));
|
||||
}
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.name {
|
||||
font-size: 44rpx;
|
||||
line-height: 1.25;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.badge {
|
||||
align-self: flex-start;
|
||||
margin-top: 4rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
border: 1px solid rgba(212, 179, 131, 0.35);
|
||||
background: rgba(212, 179, 131, 0.08);
|
||||
}
|
||||
.badge-text {
|
||||
font-size: 20rpx;
|
||||
}
|
||||
</style>
|
||||
64
blog-wot-uniapp/src/components/layout/AppFloatingTabbar.vue
Normal file
64
blog-wot-uniapp/src/components/layout/AppFloatingTabbar.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useAppTabbar } from '@/composables/useAppTabbar'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
const props = withDefaults(defineProps<{ hidden?: boolean }>(), { hidden: false })
|
||||
|
||||
const { tabItems, resolveActive, navigateTab, shouldShowTabbar } = useAppTabbar()
|
||||
const { isDark } = useTheme()
|
||||
const { appMode } = useAppMode()
|
||||
|
||||
const active = ref('home')
|
||||
const visible = ref(true)
|
||||
|
||||
const accent = '#d4b383'
|
||||
const inactive = computed(() => (isDark.value || appMode.value === 'admin' ? '#888888' : '#6b6b6b'))
|
||||
const barStyle = computed(() => {
|
||||
const dark = isDark.value || appMode.value === 'admin'
|
||||
const bg = dark ? 'rgba(18,18,20,0.95)' : 'rgba(255,255,255,0.95)'
|
||||
return `background:${bg};backdrop-filter:blur(20px);`
|
||||
})
|
||||
|
||||
const refresh = () => {
|
||||
const pages = getCurrentPages()
|
||||
const cur = pages[pages.length - 1] as { route?: string } | undefined
|
||||
const path = cur?.route ? `/${cur.route}` : ''
|
||||
active.value = resolveActive(path)
|
||||
visible.value = !props.hidden && shouldShowTabbar(path)
|
||||
}
|
||||
|
||||
onShow(refresh)
|
||||
watch(() => props.hidden, refresh)
|
||||
watch(isDark, refresh)
|
||||
|
||||
const onChange = ({ value }: { value: string | number }) => {
|
||||
navigateTab(String(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<wd-tabbar
|
||||
v-if="visible"
|
||||
v-model="active"
|
||||
shape="round"
|
||||
fixed
|
||||
:bordered="false"
|
||||
:active-color="accent"
|
||||
:inactive-color="inactive"
|
||||
safe-area-inset-bottom
|
||||
:z-index="500"
|
||||
:custom-style="barStyle"
|
||||
@change="onChange"
|
||||
>
|
||||
<wd-tabbar-item
|
||||
v-for="item in tabItems"
|
||||
:key="item.name"
|
||||
:name="item.name"
|
||||
:title="item.title"
|
||||
:icon="item.icon"
|
||||
/>
|
||||
</wd-tabbar>
|
||||
</template>
|
||||
100
blog-wot-uniapp/src/components/layout/AppNavbar.vue
Normal file
100
blog-wot-uniapp/src/components/layout/AppNavbar.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 全局导航栏封装:按主题注入 --wot-navbar-*,保证暗色下标题与返回箭头可见。
|
||||
* wd-navbar 内部用 CSS 变量着色,仅设 custom-style 的 color 往往无效。
|
||||
*/
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
showBack?: boolean
|
||||
transparent?: boolean
|
||||
}>(), {
|
||||
title: '',
|
||||
showBack: false,
|
||||
transparent: false,
|
||||
})
|
||||
|
||||
const { transparent } = toRefs(props)
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
const { isDark } = useTheme()
|
||||
const { appMode } = useAppMode()
|
||||
|
||||
/** 暗色:管理端或用户选深色主题 */
|
||||
const dark = computed(() => isDark.value || appMode.value === 'admin')
|
||||
|
||||
/** 前景色:标题 / 箭头共用,保证对比度 */
|
||||
const fg = computed(() => (dark.value ? '#ececec' : '#171717'))
|
||||
|
||||
/**
|
||||
* 通过 CSS 变量覆盖 wot-navbar 内部色;透明模式仍传前景色,避免黑底黑字。
|
||||
*/
|
||||
const navStyle = computed(() => {
|
||||
const color = fg.value
|
||||
const bg = transparent.value
|
||||
? 'transparent'
|
||||
: (dark.value ? 'rgba(5,5,5,0.92)' : 'rgba(250,248,244,0.92)')
|
||||
return [
|
||||
`background:${bg}`,
|
||||
`background-color:${bg}`,
|
||||
`color:${color}`,
|
||||
`--wot-navbar-bg:${bg}`,
|
||||
`--wot-navbar-color:${color}`,
|
||||
`--wot-navbar-desc-color:${color}`,
|
||||
].join(';')
|
||||
})
|
||||
|
||||
/** 返回:有栈则返回,否则回首页 */
|
||||
const onBack = () => {
|
||||
emit('back')
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) uni.navigateBack()
|
||||
else uni.reLaunch({ url: '/pages/home/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<wd-navbar
|
||||
:title="title"
|
||||
:left-arrow="showBack"
|
||||
:bordered="false"
|
||||
safe-area-inset-top
|
||||
fixed
|
||||
placeholder
|
||||
:custom-style="navStyle"
|
||||
@click-left="onBack"
|
||||
>
|
||||
<!-- 插槽兜底:部分端上箭头仍不吃变量时,用显式色的图标 -->
|
||||
<template v-if="showBack" #left>
|
||||
<view class="nav-back" @click.stop="onBack">
|
||||
<wd-icon name="left" size="22px" :color="fg" />
|
||||
</view>
|
||||
</template>
|
||||
<template v-if="title" #title>
|
||||
<text class="nav-title" :style="{ color: fg }">{{ title }}</text>
|
||||
</template>
|
||||
<template v-if="$slots.right" #right>
|
||||
<slot name="right" />
|
||||
</template>
|
||||
</wd-navbar>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.nav-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8rpx;
|
||||
height: 100%;
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
max-width: 60vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
65
blog-wot-uniapp/src/components/layout/AppPageShell.vue
Normal file
65
blog-wot-uniapp/src/components/layout/AppPageShell.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 页面壳:主题 ConfigProvider + C 端氛围层。
|
||||
* 管理端 admin-mode 不挂星空,避免后台花哨。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import AmbientBackground from '@/components/c-end/AmbientBackground.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** force admin dark shell */
|
||||
admin?: boolean
|
||||
withTabPad?: boolean
|
||||
/** 强制关闭氛围(极少用) */
|
||||
noAmbient?: boolean
|
||||
}>(), {
|
||||
admin: false,
|
||||
withTabPad: false,
|
||||
noAmbient: false,
|
||||
})
|
||||
|
||||
const { isDark, wotTheme, themeVars, apply } = useTheme()
|
||||
const { appMode } = useAppMode()
|
||||
|
||||
apply()
|
||||
|
||||
const forceDark = computed(() => props.admin || appMode.value === 'admin' || isDark.value)
|
||||
const providerTheme = computed(() => (forceDark.value ? 'dark' : wotTheme.value))
|
||||
const showAmbient = computed(() => (
|
||||
!props.noAmbient && !props.admin && appMode.value !== 'admin'
|
||||
))
|
||||
|
||||
const rootClass = computed(() => [
|
||||
'page-root',
|
||||
'page-shell',
|
||||
props.withTabPad ? 'page-pad-tab' : '',
|
||||
forceDark.value ? 'theme-dark' : '',
|
||||
props.admin || appMode.value === 'admin' ? 'admin-mode' : '',
|
||||
])
|
||||
|
||||
/** 内联兜底:个别端未吃到全局 .page-root 的 100vh 时仍铺满视口 */
|
||||
const rootStyle = 'min-height:100vh;position:relative;'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<wd-config-provider :theme="providerTheme" :theme-vars="themeVars">
|
||||
<view :class="rootClass" :style="rootStyle">
|
||||
<AmbientBackground v-if="showAmbient" />
|
||||
<view class="page-shell__content">
|
||||
<slot />
|
||||
</view>
|
||||
</view>
|
||||
<wd-toast />
|
||||
<wd-dialog />
|
||||
</wd-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
75
blog-wot-uniapp/src/composables/useAppMode.ts
Normal file
75
blog-wot-uniapp/src/composables/useAppMode.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { AppMode, LoginType } from '@/config/menus'
|
||||
import { isStaffRole } from '@/config/menus'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
const MODE_KEY = 'appMode'
|
||||
const LOGIN_TYPE_KEY = 'loginType'
|
||||
|
||||
const appMode = ref<AppMode>((uni.getStorageSync(MODE_KEY) as AppMode) || 'user')
|
||||
const loginType = ref<LoginType>((uni.getStorageSync(LOGIN_TYPE_KEY) as LoginType) || 'guest')
|
||||
|
||||
export function useAppMode() {
|
||||
const { getUser, isAuthenticated } = useAuth()
|
||||
|
||||
const isAdminMode = computed(() => appMode.value === 'admin')
|
||||
const isUserMode = computed(() => appMode.value === 'user')
|
||||
|
||||
const canEnterAdmin = computed(() => {
|
||||
if (!isAuthenticated()) return false
|
||||
const user = getUser() as { role?: string }
|
||||
return isStaffRole(user?.role)
|
||||
})
|
||||
|
||||
const setLoginType = (type: LoginType) => {
|
||||
loginType.value = type
|
||||
uni.setStorageSync(LOGIN_TYPE_KEY, type)
|
||||
}
|
||||
|
||||
const setAppMode = (mode: AppMode) => {
|
||||
appMode.value = mode
|
||||
uni.setStorageSync(MODE_KEY, mode)
|
||||
}
|
||||
|
||||
const enterAdminMode = () => {
|
||||
if (!canEnterAdmin.value) {
|
||||
uni.showToast({ title: '无后台权限', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
setAppMode('admin')
|
||||
// 延迟 import,避免与 useTheme 循环依赖;切换模式后立刻同步页底色
|
||||
import('./useTheme').then(({ useTheme }) => useTheme().apply()).catch(() => {})
|
||||
uni.reLaunch({ url: '/subPackages/admin/pages/dashboard/index' })
|
||||
return true
|
||||
}
|
||||
|
||||
const enterUserMode = () => {
|
||||
setAppMode('user')
|
||||
import('./useTheme').then(({ useTheme }) => useTheme().apply()).catch(() => {})
|
||||
uni.reLaunch({ url: '/pages/home/index' })
|
||||
}
|
||||
|
||||
/** 启动时校验:admin 模式但无权限则回退 */
|
||||
const hydrateAppMode = () => {
|
||||
if (appMode.value === 'admin' && !canEnterAdmin.value) {
|
||||
setAppMode('user')
|
||||
if (!isAuthenticated()) setLoginType('guest')
|
||||
}
|
||||
if (isAuthenticated() && canEnterAdmin.value) {
|
||||
setLoginType('staff')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appMode,
|
||||
loginType,
|
||||
isAdminMode,
|
||||
isUserMode,
|
||||
canEnterAdmin,
|
||||
setLoginType,
|
||||
setAppMode,
|
||||
enterAdminMode,
|
||||
enterUserMode,
|
||||
hydrateAppMode,
|
||||
}
|
||||
}
|
||||
101
blog-wot-uniapp/src/composables/useAppTabbar.ts
Normal file
101
blog-wot-uniapp/src/composables/useAppTabbar.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAppMode } from './useAppMode'
|
||||
import { useTheme } from './useTheme'
|
||||
|
||||
export interface TabItem {
|
||||
name: string
|
||||
title: string
|
||||
icon: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const USER_TABS: TabItem[] = [
|
||||
{ name: 'home', title: '首页', icon: 'home', path: '/pages/home/index' },
|
||||
// wot-ui 无 app,正确图标名为 apps
|
||||
{ name: 'explore', title: '功能入口', icon: 'apps', path: '/pages/explore/index' },
|
||||
{ name: 'mine', title: '我的', icon: 'user', path: '/pages/mine/index' },
|
||||
]
|
||||
|
||||
const ADMIN_TABS: TabItem[] = [
|
||||
{ name: 'dashboard', title: '工作台', icon: 'dashboard', path: '/subPackages/admin/pages/dashboard/index' },
|
||||
{ name: 'explore', title: '功能入口', icon: 'apps', path: '/subPackages/admin/pages/explore/index' },
|
||||
{ name: 'mine', title: '我的', icon: 'user', path: '/pages/mine/index' },
|
||||
]
|
||||
|
||||
export function useAppTabbar() {
|
||||
const { appMode } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
const tabItems = computed(() => (appMode.value === 'admin' ? ADMIN_TABS : USER_TABS))
|
||||
|
||||
const resolveActive = (routePath?: string): string => {
|
||||
const path = routePath || getCurrentRoutePath()
|
||||
const items = tabItems.value
|
||||
const hit = items.find(t => path.includes(t.path.replace(/^\//, '')) || path.endsWith(t.name))
|
||||
if (hit) return hit.name
|
||||
if (path.includes('/mine')) return 'mine'
|
||||
if (path.includes('/explore')) return 'explore'
|
||||
if (path.includes('/dashboard') || path.includes('/home')) return items[0].name
|
||||
return items[0].name
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab 切换:先同步底色再 redirectTo,避免 reLaunch 清栈 + 浅色 page 闪屏。
|
||||
* 已在当前页则 no-op。
|
||||
*/
|
||||
const navigateTab = (name: string) => {
|
||||
const item = tabItems.value.find(t => t.name === name)
|
||||
if (!item) return
|
||||
const cur = getCurrentRoutePath()
|
||||
const target = item.path
|
||||
if (cur === target || cur.endsWith(target.replace(/^\//, ''))) return
|
||||
apply()
|
||||
uni.redirectTo({ url: target })
|
||||
}
|
||||
|
||||
const shouldShowTabbar = (routePath?: string): boolean => {
|
||||
const path = routePath || getCurrentRoutePath()
|
||||
const hidePatterns = [
|
||||
'/blog/detail',
|
||||
'/blog/ppt',
|
||||
'/columns/detail',
|
||||
'/works/detail',
|
||||
'/videos/detail',
|
||||
'/videos/album',
|
||||
'/resource/form',
|
||||
'/resource/list',
|
||||
'/analytics',
|
||||
'/columns/form',
|
||||
'/posts/',
|
||||
'/works/list',
|
||||
'/works/form',
|
||||
'/videos/list',
|
||||
'/videos/form',
|
||||
'/video-albums/',
|
||||
'/snippets/form',
|
||||
'/attachments/',
|
||||
'/inquiries/',
|
||||
'/about/index',
|
||||
'/settings/',
|
||||
'/oss-configs/',
|
||||
'/users/',
|
||||
'/logs/',
|
||||
]
|
||||
return !hidePatterns.some(p => path.includes(p))
|
||||
}
|
||||
|
||||
return {
|
||||
tabItems,
|
||||
resolveActive,
|
||||
navigateTab,
|
||||
shouldShowTabbar,
|
||||
USER_TABS,
|
||||
ADMIN_TABS,
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentRoutePath(): string {
|
||||
const pages = getCurrentPages()
|
||||
const cur = pages[pages.length - 1] as { route?: string } | undefined
|
||||
return cur?.route ? `/${cur.route}` : ''
|
||||
}
|
||||
117
blog-wot-uniapp/src/composables/useAuth.ts
Normal file
117
blog-wot-uniapp/src/composables/useAuth.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ref } from 'vue'
|
||||
import { isStaffRole } from '@/config/menus'
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
const USER_KEY = 'user'
|
||||
const EXPIRE_KEY = 'tokenExpireAt'
|
||||
|
||||
const sessionExpired = ref(false)
|
||||
let sessionExpiredHandled = false
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
role?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const getToken = () => uni.getStorageSync(TOKEN_KEY) as string || ''
|
||||
|
||||
const getTokenExpireAt = (): number => {
|
||||
const raw = uni.getStorageSync(EXPIRE_KEY)
|
||||
return raw ? Number(raw) : 0
|
||||
}
|
||||
|
||||
const isAuthenticated = (): boolean => {
|
||||
const token = getToken()
|
||||
if (!token) return false
|
||||
const expireAt = getTokenExpireAt()
|
||||
if (expireAt > 0 && Date.now() >= expireAt) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const getUser = (): AuthUser => {
|
||||
try {
|
||||
const raw = uni.getStorageSync(USER_KEY)
|
||||
if (!raw) return {} as AuthUser
|
||||
return typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
}
|
||||
catch {
|
||||
return {} as AuthUser
|
||||
}
|
||||
}
|
||||
|
||||
const setUser = (user: AuthUser) => {
|
||||
uni.setStorageSync(USER_KEY, JSON.stringify(user))
|
||||
}
|
||||
|
||||
const clearExpiryTimer = () => {
|
||||
if (expiryTimer) {
|
||||
clearTimeout(expiryTimer)
|
||||
expiryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleSessionExpired = () => {
|
||||
if (sessionExpiredHandled) return
|
||||
sessionExpiredHandled = true
|
||||
sessionExpired.value = true
|
||||
}
|
||||
|
||||
const scheduleExpiryCheck = () => {
|
||||
clearExpiryTimer()
|
||||
const expireAt = getTokenExpireAt()
|
||||
if (!expireAt) return
|
||||
const delay = expireAt - Date.now()
|
||||
if (delay <= 0) {
|
||||
handleSessionExpired()
|
||||
return
|
||||
}
|
||||
expiryTimer = setTimeout(() => handleSessionExpired(), delay)
|
||||
}
|
||||
|
||||
const login = (token: string, user: AuthUser, expireUnixSeconds: number) => {
|
||||
uni.setStorageSync(TOKEN_KEY, token)
|
||||
uni.setStorageSync(USER_KEY, JSON.stringify(user))
|
||||
uni.setStorageSync(EXPIRE_KEY, String(expireUnixSeconds * 1000))
|
||||
sessionExpiredHandled = false
|
||||
sessionExpired.value = false
|
||||
scheduleExpiryCheck()
|
||||
|
||||
// sync loginType
|
||||
uni.setStorageSync('loginType', isStaffRole(user.role) ? 'staff' : 'guest')
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
clearExpiryTimer()
|
||||
uni.removeStorageSync(TOKEN_KEY)
|
||||
uni.removeStorageSync(USER_KEY)
|
||||
uni.removeStorageSync(EXPIRE_KEY)
|
||||
uni.setStorageSync('loginType', 'guest')
|
||||
uni.setStorageSync('appMode', 'user')
|
||||
sessionExpiredHandled = false
|
||||
sessionExpired.value = false
|
||||
}
|
||||
|
||||
const confirmSessionExpired = () => {
|
||||
logout()
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
}
|
||||
|
||||
return {
|
||||
sessionExpired,
|
||||
getToken,
|
||||
getUser,
|
||||
setUser,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
handleSessionExpired,
|
||||
confirmSessionExpired,
|
||||
scheduleExpiryCheck,
|
||||
}
|
||||
}
|
||||
53
blog-wot-uniapp/src/composables/usePublicSettings.ts
Normal file
53
blog-wot-uniapp/src/composables/usePublicSettings.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ref } from 'vue'
|
||||
import { get } from '@/utils/request'
|
||||
import { PUBLIC_MENUS } from '@/config/menus'
|
||||
|
||||
export type PublicSettings = Record<string, string>
|
||||
|
||||
const cache = ref<PublicSettings>({})
|
||||
const loaded = ref(false)
|
||||
|
||||
export function usePublicSettings() {
|
||||
const load = async (force = false) => {
|
||||
if (loaded.value && !force) return cache.value
|
||||
try {
|
||||
const data = await get<PublicSettings>('/settings')
|
||||
cache.value = data || {}
|
||||
loaded.value = true
|
||||
}
|
||||
catch {
|
||||
cache.value = {}
|
||||
}
|
||||
return cache.value
|
||||
}
|
||||
|
||||
const getStringSetting = (key: string, fallback = '') => cache.value[key] || fallback
|
||||
|
||||
const getVisibleMenus = (): string[] => {
|
||||
const raw = getStringSetting('visible_menus')
|
||||
const defaults = ['home', 'blog', 'columns', 'works', 'videos', 'snippets', 'about', 'services']
|
||||
if (!raw) return defaults
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
}
|
||||
catch {
|
||||
return raw.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
const getExploreMenus = () => {
|
||||
const visible = getVisibleMenus()
|
||||
return PUBLIC_MENUS.filter(m => visible.includes(m.key))
|
||||
}
|
||||
|
||||
return {
|
||||
cache,
|
||||
loaded,
|
||||
load,
|
||||
getStringSetting,
|
||||
getVisibleMenus,
|
||||
getExploreMenus,
|
||||
}
|
||||
}
|
||||
50
blog-wot-uniapp/src/composables/useSearchHistory.ts
Normal file
50
blog-wot-uniapp/src/composables/useSearchHistory.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 思考页搜索历史:对齐 PC blog_search_history,最多 10 条。
|
||||
* 小程序用 uni.setStorageSync,与 localStorage key 保持一致便于跨端对齐。
|
||||
*/
|
||||
export const SEARCH_HISTORY_KEY = 'blog_search_history'
|
||||
const MAX_HISTORY = 10
|
||||
|
||||
export function useSearchHistory() {
|
||||
/** 读取历史列表 */
|
||||
const readHistory = (): string[] => {
|
||||
try {
|
||||
const raw = uni.getStorageSync(SEARCH_HISTORY_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const writeHistory = (items: string[]) => {
|
||||
uni.setStorageSync(SEARCH_HISTORY_KEY, JSON.stringify(items.slice(0, MAX_HISTORY)))
|
||||
}
|
||||
|
||||
const getHistory = () => readHistory()
|
||||
|
||||
/** 写入关键词(去重置顶) */
|
||||
const addHistory = (keyword: string) => {
|
||||
const trimmed = keyword.trim()
|
||||
if (!trimmed) return
|
||||
const next = [trimmed, ...readHistory().filter(item => item !== trimmed)].slice(0, MAX_HISTORY)
|
||||
writeHistory(next)
|
||||
}
|
||||
|
||||
const removeHistory = (keyword: string) => {
|
||||
writeHistory(readHistory().filter(item => item !== keyword))
|
||||
}
|
||||
|
||||
const clearHistory = () => {
|
||||
uni.removeStorageSync(SEARCH_HISTORY_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
getHistory,
|
||||
addHistory,
|
||||
removeHistory,
|
||||
clearHistory,
|
||||
}
|
||||
}
|
||||
145
blog-wot-uniapp/src/composables/useTheme.ts
Normal file
145
blog-wot-uniapp/src/composables/useTheme.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 主题与 wot ConfigProvider 变量。
|
||||
* 后台模式强制按暗色注入,避免用户浅色偏好导致表单字色/底色错乱。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAppMode } from './useAppMode'
|
||||
|
||||
export type ThemePref = 'system' | 'light' | 'dark'
|
||||
|
||||
const THEME_KEY = 'theme-pref'
|
||||
const themePref = ref<ThemePref>((uni.getStorageSync(THEME_KEY) as ThemePref) || 'system')
|
||||
const isDark = ref(false)
|
||||
|
||||
function resolveDark(pref: ThemePref): boolean {
|
||||
if (pref === 'dark') return true
|
||||
if (pref === 'light') return false
|
||||
try {
|
||||
// 优先新 API,避免 wx.getSystemInfoSync 弃用警告
|
||||
const base = typeof uni.getAppBaseInfo === 'function' ? uni.getAppBaseInfo() : null
|
||||
if (base && 'theme' in base) {
|
||||
return (base as { theme?: string }).theme === 'dark'
|
||||
}
|
||||
const info = uni.getSystemInfoSync()
|
||||
return (info as UniApp.GetSystemInfoResult & { theme?: string }).theme === 'dark'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步窗口底色到 art 主题色。
|
||||
* theme.json 的 @bgColor 只跟系统深色;App 内选手动深色时必须靠此 API,避免 page 露白。
|
||||
*/
|
||||
function syncPageBackground(dark: boolean, admin = false) {
|
||||
const bg = admin ? '#0a0a0a' : dark ? '#050505' : '#faf8f4'
|
||||
try {
|
||||
uni.setBackgroundColor({
|
||||
backgroundColor: bg,
|
||||
backgroundColorTop: bg,
|
||||
backgroundColorBottom: bg,
|
||||
})
|
||||
}
|
||||
catch { /* H5 may ignore */ }
|
||||
try {
|
||||
uni.setNavigationBarColor({
|
||||
frontColor: dark || admin ? '#ffffff' : '#000000',
|
||||
backgroundColor: bg,
|
||||
})
|
||||
}
|
||||
catch { /* custom nav may ignore */ }
|
||||
uni.setStorageSync('theme-bg-sync', `${dark || admin ? 1 : 0}-${Date.now()}`)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const { appMode } = useAppMode()
|
||||
|
||||
/** 用户偏好暗色,或当前处于后台模式 */
|
||||
const effectiveDark = computed(() => isDark.value || appMode.value === 'admin')
|
||||
|
||||
const apply = () => {
|
||||
isDark.value = resolveDark(themePref.value)
|
||||
syncPageBackground(isDark.value, appMode.value === 'admin')
|
||||
}
|
||||
|
||||
const setThemePref = (pref: ThemePref) => {
|
||||
themePref.value = pref
|
||||
uni.setStorageSync(THEME_KEY, pref)
|
||||
apply()
|
||||
}
|
||||
|
||||
const themeLabel = computed(() => {
|
||||
if (themePref.value === 'system') return '跟随系统'
|
||||
if (themePref.value === 'light') return '浅色'
|
||||
return '深色'
|
||||
})
|
||||
|
||||
const wotTheme = computed(() => (effectiveDark.value ? 'dark' : 'light'))
|
||||
|
||||
/** 注入 wot ConfigProvider:主题色 + 导航/弹层/表单/搜索深色,避免白底割裂 */
|
||||
const themeVars = computed(() => {
|
||||
const dark = effectiveDark.value
|
||||
const admin = appMode.value === 'admin'
|
||||
const bg = dark ? '#121214' : '#ffffff'
|
||||
const pageBg = admin ? '#0a0a0a' : dark ? '#050505' : '#faf8f4'
|
||||
const title = dark ? '#ececec' : '#171717'
|
||||
const muted = dark ? '#888888' : '#6b6b6b'
|
||||
const border = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'
|
||||
return {
|
||||
colorTheme: '#d4b383',
|
||||
navbarColor: title,
|
||||
navbarBg: admin
|
||||
? 'rgba(10,10,10,0.92)'
|
||||
: dark
|
||||
? 'rgba(5,5,5,0.92)'
|
||||
: 'rgba(250,248,244,0.92)',
|
||||
navbarDescColor: title,
|
||||
colorBg: bg,
|
||||
colorTitle: title,
|
||||
colorContent: title,
|
||||
colorSecondary: muted,
|
||||
darkColor: bg,
|
||||
darkBackground: pageBg,
|
||||
tabsNavBg: 'transparent',
|
||||
tabsNavColor: muted,
|
||||
tabsNavActiveColor: title,
|
||||
tabsNavLineColor: '#d4b383',
|
||||
tabsNavBorder: border,
|
||||
cellBg: bg,
|
||||
cellTitleColor: title,
|
||||
cellValueColor: muted,
|
||||
cellLabelColor: muted,
|
||||
cellBorderColor: border,
|
||||
collapseBg: bg,
|
||||
collapseBodyBg: bg,
|
||||
collapseHeaderBg: bg,
|
||||
collapseTitleColor: title,
|
||||
/* 后台表单需要可见底色,不能跟卡片同色透明 */
|
||||
inputBg: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)',
|
||||
inputInnerColor: title,
|
||||
inputInnerPlaceholderColor: muted,
|
||||
inputBorderColor: border,
|
||||
textareaBg: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)',
|
||||
textareaInnerColor: title,
|
||||
textareaInnerPlaceholderColor: muted,
|
||||
searchInputBg: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
searchInputColor: title,
|
||||
searchPlaceholderColor: muted,
|
||||
searchIconColor: muted,
|
||||
}
|
||||
})
|
||||
|
||||
apply()
|
||||
|
||||
return {
|
||||
themePref,
|
||||
isDark,
|
||||
effectiveDark,
|
||||
themeLabel,
|
||||
wotTheme,
|
||||
themeVars,
|
||||
setThemePref,
|
||||
apply,
|
||||
}
|
||||
}
|
||||
107
blog-wot-uniapp/src/config/menus.ts
Normal file
107
blog-wot-uniapp/src/config/menus.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
export type AppMode = 'user' | 'admin'
|
||||
export type LoginType = 'guest' | 'staff'
|
||||
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
title: string
|
||||
path: string
|
||||
icon?: string
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
/** 前台频道(功能入口)——路径指向 content 分包(单层,勿重复前缀) */
|
||||
export const PUBLIC_MENUS: MenuItem[] = [
|
||||
{ key: 'blog', title: '思考', path: '/subPackages/content/pages/blog/index', icon: 'edit' },
|
||||
{ key: 'columns', title: '专栏', path: '/subPackages/content/pages/columns/index', icon: 'book' },
|
||||
{ key: 'works', title: '作品', path: '/subPackages/content/pages/works/index', icon: 'image' },
|
||||
{ key: 'videos', title: '视频', path: '/subPackages/content/pages/videos/index', icon: 'video-camera' },
|
||||
{ key: 'snippets', title: '代码', path: '/subPackages/content/pages/snippets/index', icon: 'code' },
|
||||
{ key: 'about', title: '关于', path: '/subPackages/content/pages/about/index', icon: 'user' },
|
||||
{ key: 'services', title: '合作', path: '/subPackages/content/pages/services/index', icon: 'headset' },
|
||||
]
|
||||
|
||||
/** 后台菜单树(功能入口);icon 须为 @wot-ui v2 有效名 */
|
||||
export const ADMIN_MENUS: MenuItem[] = [
|
||||
{ key: 'dashboard', title: '概览', path: '/subPackages/admin/pages/dashboard/index', icon: 'dashboard' },
|
||||
{ key: 'analytics', title: '数据分析', path: '/subPackages/admin/pages/analytics/index', icon: 'mind-mapping' },
|
||||
{
|
||||
key: 'content',
|
||||
title: '内容管理',
|
||||
path: '',
|
||||
icon: 'book',
|
||||
children: [
|
||||
{ key: 'posts', title: '文章管理', path: '/subPackages/admin/pages/posts/list', icon: 'edit' },
|
||||
{ key: 'categories', title: '分类管理', path: '/subPackages/admin/pages/resource/list?resource=categories', icon: 'list' },
|
||||
{ key: 'columns', title: '专栏管理', path: '/subPackages/admin/pages/resource/list?resource=columns', icon: 'book' },
|
||||
{ key: 'works', title: '作品管理', path: '/subPackages/admin/pages/works/list', icon: 'image' },
|
||||
{ key: 'video-categories', title: '视频分类', path: '/subPackages/admin/pages/resource/list?resource=video-categories', icon: 'list' },
|
||||
{ key: 'video-albums', title: '视频专辑', path: '/subPackages/admin/pages/resource/list?resource=video-albums', icon: 'folder' },
|
||||
{ key: 'videos', title: '视频列表', path: '/subPackages/admin/pages/videos/list', icon: 'video-camera' },
|
||||
{ key: 'snippets', title: '代码片段', path: '/subPackages/admin/pages/resource/list?resource=snippets', icon: 'code' },
|
||||
{ key: 'code-types', title: '代码分类', path: '/subPackages/admin/pages/resource/list?resource=code-types', icon: 'code-block' },
|
||||
{ key: 'tags', title: '标签管理', path: '/subPackages/admin/pages/resource/list?resource=tags', icon: 'apps' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pages',
|
||||
title: '页面配置',
|
||||
path: '',
|
||||
icon: 'application',
|
||||
children: [
|
||||
{ key: 'about', title: '关于页面', path: '/subPackages/admin/pages/about/index', icon: 'user' },
|
||||
{ key: 'testimonials', title: '客户评价', path: '/subPackages/admin/pages/resource/list?resource=testimonials', icon: 'message' },
|
||||
{ key: 'partners', title: '合作伙伴', path: '/subPackages/admin/pages/resource/list?resource=partners', icon: 'user-group' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
title: '业务中心',
|
||||
path: '',
|
||||
icon: 'email',
|
||||
children: [
|
||||
{ key: 'inquiries', title: '合作咨询', path: '/subPackages/admin/pages/inquiries/list', icon: 'email' },
|
||||
{ key: 'email-suffixes', title: '邮箱配置', path: '/subPackages/admin/pages/resource/list?resource=email-suffixes', icon: 'settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
title: '用户权限',
|
||||
path: '',
|
||||
icon: 'user-group',
|
||||
children: [
|
||||
{ key: 'users', title: '用户管理', path: '/subPackages/admin/pages/users/list', icon: 'user' },
|
||||
{ key: 'roles', title: '角色管理', path: '/subPackages/admin/pages/resource/list?resource=roles', icon: 'lock' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
title: '附件管理',
|
||||
path: '',
|
||||
icon: 'folder',
|
||||
children: [
|
||||
{ key: 'attachments', title: '附件库', path: '/subPackages/admin/pages/attachments/index', icon: 'file' },
|
||||
{ key: 'attachment-categories', title: '附件分类', path: '/subPackages/admin/pages/resource/list?resource=attachment-categories', icon: 'folder-add' },
|
||||
{ key: 'oss-configs', title: 'OSS配置', path: '/subPackages/admin/pages/oss-configs/index', icon: 'cloud' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
title: '系统设置',
|
||||
path: '',
|
||||
icon: 'settings',
|
||||
children: [
|
||||
{ key: 'settings', title: '全局配置', path: '/subPackages/admin/pages/settings/index', icon: 'settings' },
|
||||
{ key: 'ppt-templates', title: 'PPT 模板', path: '/subPackages/admin/pages/resource/list?resource=ppt-templates', icon: 'file' },
|
||||
{ key: 'logs', title: '操作日志', path: '/subPackages/admin/pages/logs/operation', icon: 'list' },
|
||||
{ key: 'access-logs', title: '访问日志', path: '/subPackages/admin/pages/logs/access', icon: 'eye' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const STAFF_ROLES = ['admin', 'editor'] as const
|
||||
|
||||
export function isStaffRole(role?: string): boolean {
|
||||
if (!role) return false
|
||||
const r = role.toLowerCase()
|
||||
return STAFF_ROLES.includes(r as (typeof STAFF_ROLES)[number]) || r.includes('admin') || r.includes('editor')
|
||||
}
|
||||
10
blog-wot-uniapp/src/env.d.ts
vendored
Normal file
10
blog-wot-uniapp/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string
|
||||
readonly VITE_UPLOAD_BASE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
12
blog-wot-uniapp/src/main.ts
Normal file
12
blog-wot-uniapp/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import * as Pinia from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
app.use(Pinia.createPinia())
|
||||
return {
|
||||
app,
|
||||
Pinia,
|
||||
}
|
||||
}
|
||||
74
blog-wot-uniapp/src/manifest.json
Normal file
74
blog-wot-uniapp/src/manifest.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name" : "年糕崽崽",
|
||||
"appid" : "__UNI__184857B",
|
||||
"description" : "年糕崽崽数字花园 · UniApp",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"h5" : {
|
||||
"darkmode" : true,
|
||||
"themeLocation" : "theme.json"
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx8f3891eaad380949",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"darkmode" : true,
|
||||
"themeLocation" : "theme.json",
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"lazyCodeLoading" : "requiredComponents"
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
348
blog-wot-uniapp/src/pages.json
Normal file
348
blog-wot-uniapp/src/pages.json
Normal file
@@ -0,0 +1,348 @@
|
||||
{
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^wd-(.*)": "@wot-ui/ui/components/wd-$1/wd-$1.vue",
|
||||
"^mp-html": "mp-html/dist/uni-app/components/mp-html/mp-html.vue",
|
||||
"^Content(.*)": "@/subPackages/content/components/Content$1.vue",
|
||||
"^Staff(.*)": "@/subPackages/content/components/Staff$1.vue"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/home/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "首页",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/explore/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "功能入口",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "我的",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "subPackages/content",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/theme/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "主题"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/blog/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "思考"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/blog/detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "文章",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/blog/ppt",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "PPT",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/columns/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "专栏"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/columns/detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "专栏详情",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/works/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "作品"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/works/detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "作品详情",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/videos/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "视频"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/videos/detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "视频详情",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/videos/album",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "专辑",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/snippets/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "代码"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/services/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "合作"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/about/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "关于"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "subPackages/admin",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/dashboard/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "工作台"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/explore/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "功能入口"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/analytics/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "数据分析"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/resource/list",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "列表"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/resource/form",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "个人中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/posts/list",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "文章管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/posts/form",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑文章"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/works/list",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "作品管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/works/form",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑作品"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/videos/list",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "视频列表"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/videos/form",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑视频"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/video-albums/form",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑专辑"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/snippets/form",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑代码"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/columns/form",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑专栏"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/attachments/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "附件库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/inquiries/list",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "合作咨询"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/about/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "关于页面"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/settings/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "全局配置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/oss-configs/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "OSS配置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/users/list",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "用户管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/users/form",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "编辑用户"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/logs/operation",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "操作日志"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/logs/access",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "访问日志"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"preloadRule": {
|
||||
"pages/home/index": {
|
||||
"network": "all",
|
||||
"packages": ["subPackages/content"]
|
||||
},
|
||||
"pages/explore/index": {
|
||||
"network": "all",
|
||||
"packages": ["subPackages/content"]
|
||||
}
|
||||
},
|
||||
"globalStyle": {
|
||||
"backgroundColor": "@bgColor",
|
||||
"backgroundColorBottom": "@bgColorBottom",
|
||||
"backgroundColorTop": "@bgColorTop",
|
||||
"backgroundTextStyle": "@bgTxtStyle",
|
||||
"navigationBarBackgroundColor": "@navBgColor",
|
||||
"navigationBarTextStyle": "@navTxtStyle",
|
||||
"navigationBarTitleText": "年糕崽崽",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
86
blog-wot-uniapp/src/pages/explore/index.vue
Normal file
86
blog-wot-uniapp/src/pages/explore/index.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 前台功能入口:2 列 art-card,图标+标题居中,无英文 key。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import type { MenuItem } from '@/config/menus'
|
||||
|
||||
const { load, getExploreMenus } = usePublicSettings()
|
||||
const { apply } = useTheme()
|
||||
const menus = ref<MenuItem[]>([])
|
||||
|
||||
/** 刷新功能入口菜单 */
|
||||
async function loadMenus() {
|
||||
apply()
|
||||
await load()
|
||||
menus.value = getExploreMenus()
|
||||
}
|
||||
|
||||
onShow(loadMenus)
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadMenus()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
const open = (item: MenuItem) => {
|
||||
uni.navigateTo({ url: item.path })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell with-tab-pad>
|
||||
<AppNavbar title="功能入口" />
|
||||
<view class="page-pad animate-reveal">
|
||||
<view class="hero-eyebrow">Navigate</view>
|
||||
<view class="section-title" style="margin-bottom: 40rpx;">探索花园</view>
|
||||
<view class="grid">
|
||||
<view
|
||||
v-for="item in menus"
|
||||
:key="item.key"
|
||||
class="art-card art-card-press item"
|
||||
@click="open(item)"
|
||||
>
|
||||
<wd-icon :name="item.icon || 'apps'" size="32px" color="rgb(212,179,131)" />
|
||||
<text class="title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-empty v-if="!menus.length" description="暂无可见频道" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24rpx;
|
||||
}
|
||||
.item {
|
||||
padding: 40rpx 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20rpx;
|
||||
min-height: 220rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.title {
|
||||
font-size: 34rpx;
|
||||
color: rgb(var(--art-text));
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
244
blog-wot-uniapp/src/pages/home/index.vue
Normal file
244
blog-wot-uniapp/src/pages/home/index.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { getPosts, getWorks, type Post, type Work } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const { load, getStringSetting } = usePublicSettings()
|
||||
const { apply } = useTheme()
|
||||
|
||||
const loading = ref(true)
|
||||
const featuredPost = ref<Post | null>(null)
|
||||
const latestWork = ref<Work | null>(null)
|
||||
|
||||
const siteTitle = computed(() => getStringSetting('site_title', '年糕崽崽'))
|
||||
// const DEFAULT_FEATURED_BG = '/static/bg/img.png'
|
||||
const DEFAULT_FEATURED_BG = 'https://blog.nailaoyun.cn/bg/img.png'
|
||||
const featuredBg = computed(() => {
|
||||
const cover = featuredPost.value?.cover
|
||||
if (cover) return resolveMediaUrl(String(cover))
|
||||
try {
|
||||
const raw = getStringSetting('homepage_config')
|
||||
if (raw) {
|
||||
const cfg = JSON.parse(raw) as { bento?: { featuredPost?: { backgroundImage?: string } } }
|
||||
const bg = cfg?.bento?.featuredPost?.backgroundImage?.trim()
|
||||
if (bg) return resolveMediaUrl(bg)
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
return DEFAULT_FEATURED_BG
|
||||
})
|
||||
|
||||
async function loadHome() {
|
||||
apply()
|
||||
loading.value = true
|
||||
await load()
|
||||
try {
|
||||
const postsRes = await getPosts({ page: 1, pageSize: 1 })
|
||||
const list = Array.isArray(postsRes) ? postsRes : (postsRes as { list?: Post[] }).list || []
|
||||
featuredPost.value = list[0] || null
|
||||
}
|
||||
catch { featuredPost.value = null }
|
||||
try {
|
||||
const works = await getWorks()
|
||||
latestWork.value = works?.[0] || null
|
||||
}
|
||||
catch { latestWork.value = null }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onShow(loadHome)
|
||||
|
||||
/** 下拉刷新:重新拉首页数据 */
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadHome()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
const goPost = () => {
|
||||
if (featuredPost.value?.id) {
|
||||
uni.navigateTo({ url: `/subPackages/content/pages/blog/detail?id=${featuredPost.value.id}` })
|
||||
}
|
||||
}
|
||||
const goWork = () => {
|
||||
if (latestWork.value?.id) {
|
||||
uni.navigateTo({ url: `/subPackages/content/pages/works/detail?id=${latestWork.value.id}` })
|
||||
}
|
||||
}
|
||||
const goExplore = () => {
|
||||
apply()
|
||||
uni.redirectTo({ url: '/pages/explore/index' })
|
||||
}
|
||||
const goSnippets = () => uni.navigateTo({ url: '/subPackages/content/pages/snippets/index' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell with-tab-pad>
|
||||
<AppNavbar :title="siteTitle" />
|
||||
<view class="page-pad">
|
||||
<view class="hero animate-reveal">
|
||||
<view class="hero-eyebrow">Digital Garden</view>
|
||||
<view class="hero-title font-serif-italic">
|
||||
<text class="line opacity">记录思考</text>
|
||||
<text class="line bold">与创造</text>
|
||||
</view>
|
||||
<view class="hero-bio text-art-muted">
|
||||
欢迎来到 <text class="text-art-text">{{ siteTitle }}</text> 的数字花园。
|
||||
</view>
|
||||
<view class="cta" @click="goExplore">
|
||||
<text>探索内容</text>
|
||||
<wd-icon name="arrow-right" size="16px" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="latestWork" class="work-float art-card art-card-press animate-reveal-delay" @click="goWork">
|
||||
<text class="font-mono-label text-art-muted">Latest Work</text>
|
||||
<text class="work-title font-serif-italic">{{ latestWork.title }}</text>
|
||||
</view>
|
||||
|
||||
<view class="section-head animate-reveal-delay">
|
||||
<text class="section-title">此刻</text>
|
||||
<text class="font-mono-label text-art-muted">Bento</text>
|
||||
</view>
|
||||
|
||||
<view v-if="featuredPost" class="featured art-card art-card-press animate-reveal-delay" @click="goPost">
|
||||
<!-- <image class="featured-bg" :src="featuredBg" mode="aspectFill" /> -->
|
||||
<image class="featured-bg" src="https://blog.nailaoyun.cn/bg/img.png" mode="aspectFill" />
|
||||
<view class="featured-mask" />
|
||||
<view class="featured-body">
|
||||
<view class="meta-row">
|
||||
<text class="tag">{{ featuredPost.categoryName || '未分类' }}</text>
|
||||
<text v-if="featuredPost.date" class="date">{{ featuredPost.date }}</text>
|
||||
</view>
|
||||
<text class="featured-title">{{ featuredPost.title }}</text>
|
||||
<view v-if="featuredPost.userName" class="author-row">
|
||||
<image
|
||||
class="author-avatar"
|
||||
:src="resolveMediaUrl(featuredPost.userAvatar) || '/static/default-avatar.svg'"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text class="author">{{ featuredPost.userName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="grid-2 animate-reveal-delay">
|
||||
<view class="art-card art-card-press cell" @click="goExplore">
|
||||
<text class="stat">∞</text>
|
||||
<text class="text-art-muted">功能入口</text>
|
||||
</view>
|
||||
<view class="art-card art-card-press cell" @click="goSnippets">
|
||||
<text class="code-hint font-mono-label">snippets</text>
|
||||
<text class="text-art-text">代码片段 →</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 英雄区整体水平居中,字号/字重/颜色保持原样 */
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 32rpx;
|
||||
text-align: center;
|
||||
.line { font-size: 72rpx; line-height: 1.15; color: rgb(var(--art-text)); }
|
||||
.opacity { opacity: 0.8; }
|
||||
.bold { font-style: normal; font-weight: 700; }
|
||||
}
|
||||
.hero-bio {
|
||||
font-size: 30rpx;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
border-bottom: 1px solid rgb(var(--art-text));
|
||||
padding-bottom: 8rpx;
|
||||
margin-bottom: 48rpx;
|
||||
font-size: 28rpx;
|
||||
transition: color 0.25s ease, border-color 0.25s ease;
|
||||
}
|
||||
.cta:active { color: rgb(var(--art-accent)); border-color: rgb(var(--art-accent)); }
|
||||
.work-float {
|
||||
padding: 32rpx;
|
||||
margin-bottom: 48rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
min-height: 200rpx;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.work-title { font-size: 40rpx; color: rgb(var(--art-text)); }
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
border-bottom: 1px solid var(--art-border);
|
||||
padding-bottom: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.featured {
|
||||
position: relative;
|
||||
height: 360rpx;
|
||||
margin-bottom: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
.featured-bg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.featured-mask {
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.35) 45%, rgba(0,0,0,0.1) 100%);
|
||||
}
|
||||
.featured-body {
|
||||
position: absolute; left: 32rpx; right: 32rpx; bottom: 32rpx; z-index: 2;
|
||||
display: flex; flex-direction: column; gap: 12rpx;
|
||||
}
|
||||
.meta-row { display: flex; align-items: center; gap: 16rpx; }
|
||||
.tag {
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
border-radius: 999rpx;
|
||||
color: #fff;
|
||||
}
|
||||
.date { font-size: 22rpx; color: rgba(255,255,255,0.65); }
|
||||
.featured-title { color: #fff; font-size: 36rpx; font-family: 'Playfair Display', serif; text-shadow: 0 2rpx 12rpx rgba(0,0,0,0.35); }
|
||||
.author-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.author-avatar {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,0.35);
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
.author { font-size: 24rpx; color: rgba(255,255,255,0.75); }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24rpx; }
|
||||
.cell { padding: 32rpx; min-height: 200rpx; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.stat { font-size: 56rpx; font-family: 'JetBrains Mono', monospace; color: rgb(var(--art-text)); }
|
||||
.code-hint { color: rgb(var(--art-muted)); margin-bottom: 16rpx; }
|
||||
</style>
|
||||
263
blog-wot-uniapp/src/pages/mine/index.vue
Normal file
263
blog-wot-uniapp/src/pages/mine/index.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 「我的」页:始终可访问本地设置(主题等)。
|
||||
* 账号相关入口跳转独立登录页;员工才显示后台能力。
|
||||
* 菜单默认宫格,可切换列表并本地持久化。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useToast, useDialog } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ProfileHero from '@/components/c-end/ProfileHero.vue'
|
||||
import ArtMenuGroup from '@/components/c-end/ArtMenuGroup.vue'
|
||||
import ArtMenuItem from '@/components/c-end/ArtMenuItem.vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { isStaffRole } from '@/config/menus'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const LAYOUT_KEY = 'mine-menu-layout'
|
||||
type MenuLayout = 'grid' | 'list'
|
||||
|
||||
const toast = useToast()
|
||||
/** 与页面内 wd-dialog selector 对应,确保 inject 能命中 */
|
||||
const dialog = useDialog('merge-account')
|
||||
const { themeLabel, apply } = useTheme()
|
||||
const { isAuthenticated, getUser, logout } = useAuth()
|
||||
const { appMode, canEnterAdmin, enterAdminMode, enterUserMode } = useAppMode()
|
||||
|
||||
const authed = ref(false)
|
||||
const user = ref(getUser())
|
||||
/** 菜单排版:默认宫格 */
|
||||
const menuLayout = ref<MenuLayout>('grid')
|
||||
|
||||
const DEFAULT_AVATAR = '/static/default-avatar.svg'
|
||||
|
||||
/** 从本地恢复宫格/列表偏好 */
|
||||
const loadMenuLayout = () => {
|
||||
const raw = uni.getStorageSync(LAYOUT_KEY)
|
||||
menuLayout.value = raw === 'list' ? 'list' : 'grid'
|
||||
}
|
||||
|
||||
/** 头像:未登录用默认图,已登录优先用户头像 */
|
||||
const avatarUrl = computed(() => {
|
||||
if (!authed.value) return DEFAULT_AVATAR
|
||||
return resolveMediaUrl(user.value.avatar) || DEFAULT_AVATAR
|
||||
})
|
||||
|
||||
/** 是否游客或匿名(含微信游客_前缀);须先于副文案/徽章计算 */
|
||||
const isGuestOrAnon = computed(() => {
|
||||
if (!authed.value) return true
|
||||
const u = user.value
|
||||
return !isStaffRole(u.role) || String(u.username || '').startsWith('游客_')
|
||||
})
|
||||
|
||||
/** 主标题:未登录显示「未登录」,已登录显示昵称 */
|
||||
const displayName = computed(() => {
|
||||
if (!authed.value) return '未登录'
|
||||
return user.value.username || '游客'
|
||||
})
|
||||
|
||||
/** 副文案:C 端友好说明,弱化技术角色名 */
|
||||
const displaySubtitle = computed(() => {
|
||||
if (!authed.value) return '登录后可同步更多内容'
|
||||
if (isGuestOrAnon.value) return '欢迎回来,继续探索数字花园'
|
||||
return '员工身份已就绪'
|
||||
})
|
||||
|
||||
/** 状态 pill:访客 / 角色,避免纯英文 GUEST */
|
||||
const displayBadge = computed(() => {
|
||||
if (!authed.value) return '访客'
|
||||
if (isGuestOrAnon.value) return '访客'
|
||||
const role = (user.value.role || '').toLowerCase()
|
||||
if (role.includes('admin')) return '管理员'
|
||||
if (role.includes('editor')) return '编辑'
|
||||
return user.value.role || '成员'
|
||||
})
|
||||
|
||||
const layoutLabel = computed(() => (menuLayout.value === 'grid' ? '宫格' : '列表'))
|
||||
|
||||
/** 刷新认证态、主题与排版偏好 */
|
||||
const refresh = () => {
|
||||
apply()
|
||||
loadMenuLayout()
|
||||
authed.value = isAuthenticated()
|
||||
user.value = getUser()
|
||||
}
|
||||
onShow(refresh)
|
||||
|
||||
/** 下拉刷新:同步本地登录态与主题 */
|
||||
onPullDownRefresh(() => {
|
||||
refresh()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
/** 进入独立主题页(避免 ActionSheet 被 Tabbar 遮挡) */
|
||||
const goTheme = () => {
|
||||
uni.navigateTo({ url: '/subPackages/content/pages/theme/index' })
|
||||
}
|
||||
|
||||
/** 在宫格 / 列表间切换并写入本地 */
|
||||
const toggleMenuLayout = () => {
|
||||
menuLayout.value = menuLayout.value === 'grid' ? 'list' : 'grid'
|
||||
uni.setStorageSync(LAYOUT_KEY, menuLayout.value)
|
||||
toast.success(`已切换为${layoutLabel.value}`)
|
||||
}
|
||||
|
||||
/** 跳转独立登录页(content 分包路径) */
|
||||
const goLogin = () => {
|
||||
uni.navigateTo({ url: '/subPackages/content/pages/login/index' })
|
||||
}
|
||||
|
||||
/** 打开合并账号确认(useDialog / wd-dialog),确认后进合并登录页 */
|
||||
const openMergeConfirm = async () => {
|
||||
try {
|
||||
await dialog.confirm({
|
||||
title: '合并账号',
|
||||
msg: '合并后当前游客账号将注销(逻辑删除),微信将绑定到管理员账号。是否继续?',
|
||||
})
|
||||
uni.navigateTo({ url: '/subPackages/content/pages/login/index?mode=merge' })
|
||||
}
|
||||
catch {
|
||||
/* 用户取消 */
|
||||
}
|
||||
}
|
||||
|
||||
const onLogout = () => {
|
||||
logout()
|
||||
enterUserMode()
|
||||
}
|
||||
|
||||
const goProfile = () => {
|
||||
if (appMode.value !== 'admin') {
|
||||
toast.show('请先进入后台模式')
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/profile/index' })
|
||||
}
|
||||
|
||||
const goAbout = () => {
|
||||
uni.navigateTo({ url: '/subPackages/content/pages/about/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell with-tab-pad>
|
||||
<AppNavbar title="我的" />
|
||||
<view class="page-pad">
|
||||
<ProfileHero
|
||||
:avatar-url="avatarUrl"
|
||||
:name="displayName"
|
||||
:subtitle="displaySubtitle"
|
||||
:badge="displayBadge"
|
||||
/>
|
||||
|
||||
<ArtMenuGroup eyebrow="Prefs" title="偏好设置" :layout="menuLayout">
|
||||
<ArtMenuItem
|
||||
title="主题"
|
||||
icon="settings"
|
||||
:value="themeLabel"
|
||||
is-link
|
||||
@click="goTheme"
|
||||
/>
|
||||
<ArtMenuItem
|
||||
title="菜单排版"
|
||||
label="宫格更适合浏览,列表信息更全"
|
||||
icon="apps"
|
||||
:value="layoutLabel"
|
||||
is-link
|
||||
@click="toggleMenuLayout"
|
||||
/>
|
||||
</ArtMenuGroup>
|
||||
|
||||
<ArtMenuGroup eyebrow="Garden" title="探索" :layout="menuLayout">
|
||||
<ArtMenuItem
|
||||
title="关于"
|
||||
label="了解站点与作者"
|
||||
icon="user"
|
||||
is-link
|
||||
@click="goAbout"
|
||||
/>
|
||||
</ArtMenuGroup>
|
||||
|
||||
<ArtMenuGroup eyebrow="Account" title="账号" :layout="menuLayout">
|
||||
<ArtMenuItem
|
||||
v-if="!authed"
|
||||
title="登录"
|
||||
label="管理员账号密码登录"
|
||||
icon="lock"
|
||||
is-link
|
||||
@click="goLogin"
|
||||
/>
|
||||
<ArtMenuItem
|
||||
v-if="authed && isGuestOrAnon"
|
||||
title="管理员登录"
|
||||
label="使用账号密码进入员工身份"
|
||||
icon="lock"
|
||||
is-link
|
||||
@click="goLogin"
|
||||
/>
|
||||
<ArtMenuItem
|
||||
v-if="authed && isGuestOrAnon"
|
||||
title="合并账号"
|
||||
label="验证管理员密码,绑定微信并注销当前游客"
|
||||
icon="swap"
|
||||
is-link
|
||||
@click="openMergeConfirm"
|
||||
/>
|
||||
<ArtMenuItem
|
||||
v-if="authed && !isGuestOrAnon"
|
||||
title="退出登录"
|
||||
label="清除本地会话"
|
||||
icon="poweroff"
|
||||
is-link
|
||||
@click="onLogout"
|
||||
/>
|
||||
</ArtMenuGroup>
|
||||
|
||||
<ArtMenuGroup
|
||||
v-if="canEnterAdmin || appMode === 'admin'"
|
||||
eyebrow="Staff"
|
||||
title="管理"
|
||||
:layout="menuLayout"
|
||||
>
|
||||
<ArtMenuItem
|
||||
v-if="canEnterAdmin && appMode === 'user'"
|
||||
title="进入后台"
|
||||
label="切换为管理端三栏"
|
||||
icon="home"
|
||||
is-link
|
||||
@click="enterAdminMode"
|
||||
/>
|
||||
<ArtMenuItem
|
||||
v-if="appMode === 'admin'"
|
||||
title="返回用户端"
|
||||
label="切换回前台浏览"
|
||||
icon="arrow-left"
|
||||
is-link
|
||||
@click="enterUserMode"
|
||||
/>
|
||||
<ArtMenuItem
|
||||
v-if="appMode === 'admin'"
|
||||
title="个人中心"
|
||||
icon="user"
|
||||
is-link
|
||||
@click="goProfile"
|
||||
/>
|
||||
<ArtMenuItem
|
||||
v-if="authed"
|
||||
title="当前模式"
|
||||
:value="appMode === 'admin' ? '后台' : '前台'"
|
||||
icon="eye"
|
||||
/>
|
||||
</ArtMenuGroup>
|
||||
</view>
|
||||
|
||||
<!-- selector 与 useDialog('merge-account') 配对 -->
|
||||
<wd-dialog selector="merge-account" />
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
17
blog-wot-uniapp/src/resolvers/wot-ui-resolver.ts
Normal file
17
blog-wot-uniapp/src/resolvers/wot-ui-resolver.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ComponentResolver } from '@uni-helper/vite-plugin-uni-components'
|
||||
import { kebabCase } from '@uni-helper/vite-plugin-uni-components'
|
||||
|
||||
export function WotResolver(): ComponentResolver {
|
||||
return {
|
||||
type: 'component',
|
||||
resolve: (name: string) => {
|
||||
if (name.match(/^Wd[A-Z]/)) {
|
||||
const compName = kebabCase(name)
|
||||
return {
|
||||
name,
|
||||
from: `@wot-ui/ui/components/${compName}/${compName}.vue`,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
1
blog-wot-uniapp/src/static/default-avatar.svg
Normal file
1
blog-wot-uniapp/src/static/default-avatar.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="512" height="512" viewBox="0 0 512 512"><defs><clipPath id="master_svg0_25_97"><rect x="0" y="0" width="512" height="512" rx="0"/></clipPath><clipPath id="master_svg1_25_11"><rect x="11" y="39" width="490" height="435" rx="0"/></clipPath></defs><g style="mix-blend-mode:passthrough" clip-path="url(#master_svg0_25_97)"><g clip-path="url(#master_svg1_25_11)"><g><path d="M51.4931,294.222767578125L205.214,437.551767578125C211.594,443.498767578125,220.016,446.812767578125,228.778,446.812767578125C237.54,446.812767578125,245.962,443.498767578125,252.342,437.551767578125L254.554,435.512767578125C238.306,411.638767578125,228.778,382.751767578125,228.778,351.655767578125C228.778,269.073767578125,295.812,202.124767578125,378.5,202.124767578125C402.575,202.124767578125,425.288,207.817767578125,445.45,217.842767578125C446.215,212.320767578125,446.556,206.797767578125,446.556,201.19076757812502L446.556,196.262767578125C446.556,136.874967578125,403.595,86.238267578125,344.983,76.467747578125C306.191,70.010717578125,266.719,82.669897578125,238.986,110.36716757812499L228.778,120.562467578125L218.569,110.36716757812499C190.837,82.669897578125,151.365,70.010717578125,112.573,76.467747578125C53.9601,86.238267578125,11,136.874967578125,11,196.262767578125L11,201.19076757812502C11,236.448767578125,25.6319,270.17876757812496,51.4931,294.222767578125ZM378.5,473.999767578125C446.13,473.999767578125,501,419.199767578125,501,351.655767578125C501,284.112767578125,446.13,229.312767578125,378.5,229.312767578125C310.87,229.312767578125,256,284.112767578125,256,351.655767578125C256,419.199767578125,310.87,473.999767578125,378.5,473.999767578125Z" fill="#2B9939" fill-opacity="1"/></g><g style="mix-blend-mode:passthrough"><path d="M322,415L441,415L441,293.5L419,293.5L419,393L344.5,393L344.5,293.5L322,293.5L322,415Z" fill="#FFFFFF" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
blog-wot-uniapp/src/static/github.svg
Normal file
1
blog-wot-uniapp/src/static/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" fill-rule="evenodd" d="M16 2a14 14 0 0 0-4.43 27.28c.7.13 1-.3 1-.67v-2.38c-3.89.84-4.71-1.88-4.71-1.88a3.71 3.71 0 0 0-1.62-2.05c-1.27-.86.1-.85.1-.85a2.94 2.94 0 0 1 2.14 1.45a3 3 0 0 0 4.08 1.16a2.93 2.93 0 0 1 .88-1.87c-3.1-.36-6.37-1.56-6.37-6.92a5.4 5.4 0 0 1 1.44-3.76a5 5 0 0 1 .14-3.7s1.17-.38 3.85 1.43a13.3 13.3 0 0 1 7 0c2.67-1.81 3.84-1.43 3.84-1.43a5 5 0 0 1 .14 3.7a5.4 5.4 0 0 1 1.44 3.76c0 5.38-3.27 6.56-6.39 6.91a3.33 3.33 0 0 1 .95 2.59v3.84c0 .46.25.81 1 .67A14 14 0 0 0 16 2"/></svg>
|
||||
|
After Width: | Height: | Size: 614 B |
1
blog-wot-uniapp/src/static/logo.svg
Normal file
1
blog-wot-uniapp/src/static/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="512" height="512" viewBox="0 0 512 512"><defs><clipPath id="master_svg0_25_97"><rect x="0" y="0" width="512" height="512" rx="0"/></clipPath><clipPath id="master_svg1_25_11"><rect x="11" y="39" width="490" height="435" rx="0"/></clipPath></defs><g style="mix-blend-mode:passthrough" clip-path="url(#master_svg0_25_97)"><g clip-path="url(#master_svg1_25_11)"><g><path d="M51.4931,294.222767578125L205.214,437.551767578125C211.594,443.498767578125,220.016,446.812767578125,228.778,446.812767578125C237.54,446.812767578125,245.962,443.498767578125,252.342,437.551767578125L254.554,435.512767578125C238.306,411.638767578125,228.778,382.751767578125,228.778,351.655767578125C228.778,269.073767578125,295.812,202.124767578125,378.5,202.124767578125C402.575,202.124767578125,425.288,207.817767578125,445.45,217.842767578125C446.215,212.320767578125,446.556,206.797767578125,446.556,201.19076757812502L446.556,196.262767578125C446.556,136.874967578125,403.595,86.238267578125,344.983,76.467747578125C306.191,70.010717578125,266.719,82.669897578125,238.986,110.36716757812499L228.778,120.562467578125L218.569,110.36716757812499C190.837,82.669897578125,151.365,70.010717578125,112.573,76.467747578125C53.9601,86.238267578125,11,136.874967578125,11,196.262767578125L11,201.19076757812502C11,236.448767578125,25.6319,270.17876757812496,51.4931,294.222767578125ZM378.5,473.999767578125C446.13,473.999767578125,501,419.199767578125,501,351.655767578125C501,284.112767578125,446.13,229.312767578125,378.5,229.312767578125C310.87,229.312767578125,256,284.112767578125,256,351.655767578125C256,419.199767578125,310.87,473.999767578125,378.5,473.999767578125Z" fill="#2B9939" fill-opacity="1"/></g><g style="mix-blend-mode:passthrough"><path d="M322,415L441,415L441,293.5L419,293.5L419,393L344.5,393L344.5,293.5L322,293.5L322,415Z" fill="#FFFFFF" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
48
blog-wot-uniapp/src/styles/art-fonts.scss
Normal file
48
blog-wot-uniapp/src/styles/art-fonts.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 从 PC 端 @fontsource 引入的本地字体(woff)。
|
||||
* 放在 assets(非 static):小程序 CSS 会把 <40KB 资源打成 base64,
|
||||
* 避免再拷一份到 static 造成双倍体积。
|
||||
*
|
||||
* 与 PC 差异:Noto Sans SC 单 weight 约 1.5MB,主包装不下,故未引入;
|
||||
* art-tokens 仍写 Noto 名,实际中文正文回退 PingFang SC / 系统黑体。
|
||||
* 标题/eyebrow 用 Playfair + JetBrains,与 PC 英文层对齐。
|
||||
*/
|
||||
|
||||
/* Playfair Display — 标题 / serif italic */
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('@/assets/fonts/playfair-display-latin-400-normal.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('@/assets/fonts/playfair-display-latin-400-italic.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('@/assets/fonts/playfair-display-latin-600-normal.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('@/assets/fonts/playfair-display-latin-700-normal.woff') format('woff');
|
||||
}
|
||||
|
||||
/* JetBrains Mono — eyebrow / mono label */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('@/assets/fonts/jetbrains-mono-latin-400-normal.woff') format('woff');
|
||||
}
|
||||
322
blog-wot-uniapp/src/styles/art-tokens.scss
Normal file
322
blog-wot-uniapp/src/styles/art-tokens.scss
Normal file
@@ -0,0 +1,322 @@
|
||||
/* Art design tokens — ported from client/src/style.css */
|
||||
|
||||
/* 小程序里 min-height:100% 常失效,用 100vh 铺满视口;底色走 --art-bg,避免硬编码浅色闪屏 */
|
||||
page {
|
||||
min-height: 100vh;
|
||||
background-color: rgb(var(--art-bg));
|
||||
}
|
||||
|
||||
page,
|
||||
.page-root {
|
||||
--art-bg: 250, 248, 244;
|
||||
--art-surface: 255, 255, 255;
|
||||
--art-border: rgba(0, 0, 0, 0.08);
|
||||
--art-text: 23, 23, 23;
|
||||
--art-muted: 107, 107, 107;
|
||||
--art-accent: 212, 179, 131;
|
||||
--art-error: 239, 68, 68;
|
||||
--art-admin-bg: 10, 10, 10;
|
||||
--art-admin-card: 18, 18, 20;
|
||||
--art-admin-hover: 26, 26, 28;
|
||||
--art-nav-bg: rgba(250, 248, 244, 0.9);
|
||||
--art-card-bg: rgba(255, 255, 255, 0.55);
|
||||
--art-card-border: rgba(0, 0, 0, 0.07);
|
||||
--art-hover-border: rgba(212, 179, 131, 0.55);
|
||||
--art-shadow: 0 18px 40px -12px rgba(80, 55, 20, 0.14);
|
||||
--art-overlay: rgba(0, 0, 0, 0.45);
|
||||
background-color: rgb(var(--art-bg));
|
||||
color: rgb(var(--art-text));
|
||||
font-family: 'Noto Sans SC', 'PingFang SC', 'Helvetica Neue', sans-serif;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-root.theme-dark,
|
||||
.theme-dark {
|
||||
--art-bg: 5, 5, 5;
|
||||
--art-surface: 18, 18, 20;
|
||||
--art-border: rgba(255, 255, 255, 0.08);
|
||||
--art-text: 236, 236, 236;
|
||||
--art-muted: 136, 136, 136;
|
||||
--art-accent: 212, 179, 131;
|
||||
--art-nav-bg: rgba(5, 5, 5, 0.85);
|
||||
--art-card-bg: rgba(255, 255, 255, 0.02);
|
||||
--art-card-border: rgba(255, 255, 255, 0.06);
|
||||
--art-hover-border: rgba(212, 179, 131, 0.3);
|
||||
--art-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.5);
|
||||
--art-overlay: rgba(0, 0, 0, 0.9);
|
||||
background-color: rgb(var(--art-bg));
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
|
||||
.page-root.admin-mode,
|
||||
.admin-mode {
|
||||
--art-bg: 10, 10, 10;
|
||||
--art-surface: 18, 18, 20;
|
||||
--art-text: 236, 236, 236;
|
||||
--art-muted: 136, 136, 136;
|
||||
background-color: rgb(var(--art-admin-bg));
|
||||
}
|
||||
|
||||
.glass-nav {
|
||||
background: var(--art-nav-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--art-card-border);
|
||||
}
|
||||
|
||||
.art-card {
|
||||
background: var(--art-card-bg);
|
||||
border: 1px solid var(--art-card-border);
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.art-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(212, 179, 131, 0.08) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.art-card-press:active {
|
||||
transform: translateY(-8rpx);
|
||||
border-color: var(--art-hover-border);
|
||||
box-shadow: var(--art-shadow);
|
||||
}
|
||||
|
||||
.art-card-press:active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: rgb(var(--art-admin-card));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16rpx;
|
||||
transition: background 0.25s ease, border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.admin-card:active {
|
||||
background: rgb(var(--art-admin-hover));
|
||||
border-color: rgba(212, 179, 131, 0.25);
|
||||
}
|
||||
|
||||
.text-art-accent { color: rgb(var(--art-accent)); }
|
||||
.text-art-muted { color: rgb(var(--art-muted)); }
|
||||
.text-art-text { color: rgb(var(--art-text)); }
|
||||
|
||||
.font-serif-italic {
|
||||
font-family: 'Playfair Display', 'Noto Serif SC', serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.font-mono-label {
|
||||
font-family: 'JetBrains Mono', 'Menlo', monospace;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.page-pad { padding: 32rpx 32rpx 200rpx; }
|
||||
.page-pad-tab { padding-bottom: 200rpx; }
|
||||
|
||||
.section-title {
|
||||
font-family: 'Playfair Display', 'Noto Serif SC', serif;
|
||||
font-style: italic;
|
||||
font-size: 48rpx;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
color: rgb(var(--art-accent));
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
@keyframes art-reveal {
|
||||
from { opacity: 0; transform: translateY(24rpx); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-reveal {
|
||||
animation: art-reveal 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.animate-reveal-delay {
|
||||
animation: art-reveal 0.55s cubic-bezier(0.16, 1, 0.3, 1) 0.12s both;
|
||||
}
|
||||
|
||||
.fab-float {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.fab-float:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* Article markdown (mp-html / rich content) */
|
||||
.article-md {
|
||||
font-size: 30rpx;
|
||||
line-height: 1.85;
|
||||
color: rgb(var(--art-text));
|
||||
word-break: break-word;
|
||||
}
|
||||
.article-md h1, .article-md h2, .article-md h3 {
|
||||
font-family: 'Playfair Display', 'Noto Serif SC', serif;
|
||||
margin: 1.2em 0 0.5em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.article-md h1 { font-size: 44rpx; }
|
||||
.article-md h2 { font-size: 38rpx; }
|
||||
.article-md h3 { font-size: 34rpx; }
|
||||
.article-md p { margin: 0.75em 0; }
|
||||
.article-md a { color: rgb(var(--art-accent)); text-decoration: underline; }
|
||||
.article-md blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 6rpx solid rgb(var(--art-accent));
|
||||
color: rgb(var(--art-muted));
|
||||
background: rgba(212, 179, 131, 0.08);
|
||||
}
|
||||
.article-md ul, .article-md ol { padding-left: 1.4em; margin: 0.75em 0; }
|
||||
.article-md img { max-width: 100%; border-radius: 12rpx; margin: 0.75em 0; }
|
||||
.article-md code {
|
||||
font-family: 'JetBrains Mono', Menlo, monospace;
|
||||
font-size: 0.88em;
|
||||
background: rgba(127, 127, 127, 0.12);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
.article-md pre {
|
||||
background: #1e1e1e;
|
||||
color: #e8e8e8;
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.article-md pre code { background: transparent; padding: 0; color: inherit; }
|
||||
.article-md table { width: 100%; border-collapse: collapse; margin: 1em 0; font-size: 26rpx; }
|
||||
.article-md th, .article-md td { border: 1px solid var(--art-border); padding: 12rpx; }
|
||||
|
||||
/* wot-ui 深浅色兜底:避免默认浅色组件压在 art 深色底上 */
|
||||
.page-root,
|
||||
.theme-dark {
|
||||
--wot-navbar-color: rgb(var(--art-text));
|
||||
--wot-navbar-desc-color: rgb(var(--art-text));
|
||||
--wot-navbar-bg: rgb(var(--art-bg));
|
||||
}
|
||||
|
||||
/* C 端暗色:输入保持轻量透明,贴合卡片氛围 */
|
||||
.theme-dark:not(.admin-mode) .wd-input,
|
||||
.page-root.theme-dark:not(.admin-mode) .wd-input {
|
||||
--wot-input-bg: transparent;
|
||||
--wot-color-bg: rgb(var(--art-surface));
|
||||
--wot-input-inner-color: rgb(var(--art-text));
|
||||
--wot-input-inner-placeholder-color: rgb(var(--art-muted));
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
|
||||
.theme-dark:not(.admin-mode) .wd-input__inner,
|
||||
.page-root.theme-dark:not(.admin-mode) .wd-input__inner {
|
||||
color: rgb(var(--art-text)) !important;
|
||||
}
|
||||
|
||||
.theme-dark:not(.admin-mode) .wd-textarea,
|
||||
.page-root.theme-dark:not(.admin-mode) .wd-textarea {
|
||||
--wot-textarea-bg: transparent;
|
||||
--wot-textarea-inner-color: rgb(var(--art-text));
|
||||
--wot-textarea-inner-placeholder-color: rgb(var(--art-muted));
|
||||
}
|
||||
|
||||
.theme-dark .wd-cell,
|
||||
.page-root.theme-dark .wd-cell {
|
||||
--wot-cell-bg: rgb(var(--art-surface));
|
||||
--wot-cell-title-color: rgb(var(--art-text));
|
||||
--wot-cell-value-color: rgb(var(--art-muted));
|
||||
--wot-cell-label-color: rgb(var(--art-muted));
|
||||
}
|
||||
|
||||
/* 后台表单:可见底色 + 边框,避免与 admin-card 融成一片 */
|
||||
.admin-mode .admin-card.form-card,
|
||||
.page-root.admin-mode .admin-card.form-card {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.admin-mode .wd-input,
|
||||
.page-root.admin-mode .wd-input,
|
||||
.admin-mode .form-card .wd-input,
|
||||
.page-root.admin-mode .form-card .wd-input {
|
||||
--wot-input-bg: rgba(255, 255, 255, 0.06);
|
||||
--wot-input-inner-color: rgb(var(--art-text));
|
||||
--wot-input-inner-placeholder-color: rgb(var(--art-muted));
|
||||
--wot-color-bg: rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 4rpx 8rpx;
|
||||
box-sizing: border-box;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
|
||||
.admin-mode .wd-input__inner,
|
||||
.page-root.admin-mode .wd-input__inner {
|
||||
color: rgb(var(--art-text)) !important;
|
||||
}
|
||||
|
||||
.admin-mode .wd-textarea,
|
||||
.page-root.admin-mode .wd-textarea,
|
||||
.admin-mode .form-card .wd-textarea,
|
||||
.page-root.admin-mode .form-card .wd-textarea {
|
||||
--wot-textarea-bg: rgba(255, 255, 255, 0.06);
|
||||
--wot-textarea-inner-color: rgb(var(--art-text));
|
||||
--wot-textarea-inner-placeholder-color: rgb(var(--art-muted));
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 8rpx;
|
||||
box-sizing: border-box;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
|
||||
.admin-mode .admin-card {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.admin-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.admin-form-field__label {
|
||||
font-size: 24rpx;
|
||||
color: rgb(var(--art-muted));
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ActionSheet / Popup:暗色下避免默认白底 */
|
||||
.theme-dark,
|
||||
.page-root.theme-dark {
|
||||
--wot-action-sheet-bg: rgb(var(--art-surface));
|
||||
--wot-action-sheet-color: rgb(var(--art-text));
|
||||
--wot-action-sheet-cancel-color: rgb(var(--art-muted));
|
||||
--wot-popup-bg: rgb(var(--art-surface));
|
||||
--wot-color-bg: rgb(var(--art-surface));
|
||||
--wot-color-title: rgb(var(--art-text));
|
||||
--wot-color-content: rgb(var(--art-text));
|
||||
--wot-color-secondary: rgb(var(--art-muted));
|
||||
--wot-color-border: var(--art-border);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台柱状图:金色柱 + 暗色网格,用于热门文章等排行。
|
||||
* 与折线图相同:首帧宽度可能为 0,需原生宽高 + 重试,否则小程序上空白。
|
||||
* getCurrentInstance 仅在 setup/同步生命周期有效,必须在此处缓存 proxy,
|
||||
* 否则 nextTick/setTimeout 里 .in(null) 会抛 $scope of null。
|
||||
*/
|
||||
import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
categories?: string[]
|
||||
values?: number[]
|
||||
height?: number
|
||||
}>(), {
|
||||
categories: () => [],
|
||||
values: () => [],
|
||||
height: 220,
|
||||
})
|
||||
|
||||
/** setup 阶段同步缓存,供异步 draw 使用 */
|
||||
const compProxy = getCurrentInstance()?.proxy as never
|
||||
const canvasId = `bar-${Math.random().toString(36).slice(2, 8)}`
|
||||
/** 原生 canvas 缓冲像素宽(含 dpr) */
|
||||
const canvasW = ref(300)
|
||||
const canvasH = ref(220)
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let retryCount = 0
|
||||
const MAX_RETRY = 5
|
||||
|
||||
/** 测量失败时用窗口宽度减去边距估算逻辑宽度;优先 getWindowInfo */
|
||||
function fallbackWidth(): number {
|
||||
try {
|
||||
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||||
if (win?.windowWidth) return Math.max(200, win.windowWidth - 48)
|
||||
const sys = uni.getSystemInfoSync()
|
||||
return Math.max(200, (sys.windowWidth || 375) - 48)
|
||||
} catch {
|
||||
return 300
|
||||
}
|
||||
}
|
||||
|
||||
function getDpr(): number {
|
||||
try {
|
||||
const device = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
|
||||
if (device && 'pixelRatio' in device && Number(device.pixelRatio)) {
|
||||
return Number(device.pixelRatio)
|
||||
}
|
||||
return uni.getSystemInfoSync().pixelRatio || 2
|
||||
} catch {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建选择器:有组件实例则限定在组件内,否则页面级查询(canvasId 已全局唯一)。
|
||||
* 禁止对 null 调用 .in(),否则 mp-weixin 读 $scope 直接报错。
|
||||
*/
|
||||
function createQuery() {
|
||||
const q = uni.createSelectorQuery()
|
||||
if (compProxy) return q.in(compProxy)
|
||||
return q
|
||||
}
|
||||
|
||||
/** 在逻辑像素坐标下绘制柱状图 */
|
||||
function paint(ctx: UniApp.CanvasContext, w: number, h: number, dpr: number) {
|
||||
const cats = props.categories
|
||||
const vals = props.values
|
||||
ctx.scale(dpr, dpr)
|
||||
const padL = 12
|
||||
const padR = 12
|
||||
const padT = 16
|
||||
const padB = 40
|
||||
const plotW = w - padL - padR
|
||||
const plotH = h - padT - padB
|
||||
const max = Math.max(1, ...vals)
|
||||
const n = cats.length
|
||||
const gap = 8
|
||||
const barW = Math.max(8, (plotW - gap * (n + 1)) / n)
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.setStrokeStyle('rgba(255,255,255,0.08)')
|
||||
for (let i = 0; i <= 3; i++) {
|
||||
const y = padT + (plotH * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padL, y)
|
||||
ctx.lineTo(w - padR, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
vals.forEach((v, i) => {
|
||||
const bh = (v / max) * plotH
|
||||
const x = padL + gap + i * (barW + gap)
|
||||
const y = padT + plotH - bh
|
||||
ctx.setFillStyle('rgba(212,179,131,0.85)')
|
||||
ctx.fillRect(x, y, barW, bh)
|
||||
ctx.setFillStyle('rgba(136,136,136,1)')
|
||||
ctx.setFontSize(9)
|
||||
ctx.fillText(String(cats[i] || '').slice(0, 4), x, h - 12)
|
||||
})
|
||||
ctx.draw()
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量并绘制;宽度为 0 时 fallback + 短延迟重试;
|
||||
* 先写原生宽高再 nextTick 绘制。
|
||||
*/
|
||||
const draw = (isRetry = false) => {
|
||||
const cats = props.categories
|
||||
const vals = props.values
|
||||
if (!cats.length || !vals.length) return
|
||||
if (!isRetry) retryCount = 0
|
||||
|
||||
createQuery()
|
||||
.select(`#${canvasId}`)
|
||||
.boundingClientRect((rect) => {
|
||||
const box = Array.isArray(rect) ? rect[0] : rect
|
||||
let w = box?.width || 0
|
||||
const measuredOk = !!w
|
||||
if (!w) w = fallbackWidth()
|
||||
if (!measuredOk && retryCount < MAX_RETRY) {
|
||||
retryCount += 1
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = setTimeout(() => draw(true), 80)
|
||||
} else if (measuredOk) {
|
||||
retryCount = 0
|
||||
}
|
||||
if (!w) return
|
||||
|
||||
const h = props.height
|
||||
const dpr = getDpr()
|
||||
canvasW.value = Math.floor(w * dpr)
|
||||
canvasH.value = Math.floor(h * dpr)
|
||||
nextTick(() => {
|
||||
const ctx = uni.createCanvasContext(canvasId, compProxy)
|
||||
paint(ctx, w, h, dpr)
|
||||
})
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
onMounted(() => nextTick(() => draw()))
|
||||
onUnmounted(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
})
|
||||
watch(() => [props.categories, props.values], () => nextTick(() => draw()), { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
:id="canvasId"
|
||||
:canvas-id="canvasId"
|
||||
class="chart"
|
||||
:width="canvasW"
|
||||
:height="canvasH"
|
||||
:style="{ width: '100%', height: `${height}px` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart { width: 100%; display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 基于 page-container 的详情抽屉,防止小程序侧滑误退页面。
|
||||
* 只读展示键值对,底部可插操作按钮(如咨询状态变更)。
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
show: boolean
|
||||
title?: string
|
||||
rows?: { label: string, value: string }[]
|
||||
}>(), {
|
||||
title: '详情',
|
||||
rows: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function onLeave() {
|
||||
emit('update:show', false)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<page-container :show="show" position="bottom" round :overlay="true" @afterleave="onLeave" @clickoverlay="onLeave">
|
||||
<view class="drawer">
|
||||
<view class="head">
|
||||
<text class="title">{{ title }}</text>
|
||||
<wd-icon name="close" size="20px" @click="onLeave" />
|
||||
</view>
|
||||
<scroll-view scroll-y class="body">
|
||||
<view v-for="(row, i) in rows" :key="i" class="row">
|
||||
<text class="label text-art-muted">{{ row.label }}</text>
|
||||
<text class="value">{{ row.value || '—' }}</text>
|
||||
</view>
|
||||
<slot />
|
||||
</scroll-view>
|
||||
<view v-if="$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</view>
|
||||
</view>
|
||||
</page-container>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<view v-if="show" class="mask" @click="onLeave">
|
||||
<view class="drawer" @click.stop>
|
||||
<view class="head">
|
||||
<text class="title">{{ title }}</text>
|
||||
<wd-icon name="close" size="20px" @click="onLeave" />
|
||||
</view>
|
||||
<scroll-view scroll-y class="body">
|
||||
<view v-for="(row, i) in rows" :key="i" class="row">
|
||||
<text class="label text-art-muted">{{ row.label }}</text>
|
||||
<text class="value">{{ row.value || '—' }}</text>
|
||||
</view>
|
||||
<slot />
|
||||
</scroll-view>
|
||||
<view v-if="$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.drawer {
|
||||
background: #1a1a1c;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
max-height: 75vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.title { font-size: 32rpx; color: rgb(var(--art-text)); font-weight: 600; }
|
||||
.body { max-height: 55vh; padding: 16rpx 32rpx 32rpx; box-sizing: border-box; }
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.label { font-size: 22rpx; }
|
||||
.value { font-size: 28rpx; color: rgb(var(--art-text)); word-break: break-all; }
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 32rpx 28rpx;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台列表筛选条:关键词 + 插槽筛选项 + 搜索/重置。
|
||||
* 简单资源与专用列表页复用,避免每页重写一套筛选 UI。
|
||||
*/
|
||||
import { reactive, watch } from 'vue'
|
||||
import type { SchemaFilter } from '../config/resourceSchemas'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
keyword?: string
|
||||
placeholder?: string
|
||||
filters?: SchemaFilter[]
|
||||
modelValue?: Record<string, unknown>
|
||||
}>(), {
|
||||
keyword: '',
|
||||
placeholder: '搜索关键词',
|
||||
filters: () => [],
|
||||
modelValue: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [string]
|
||||
'update:modelValue': [Record<string, unknown>]
|
||||
search: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const local = reactive<Record<string, unknown>>({ ...props.modelValue })
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
Object.keys(local).forEach(k => delete local[k])
|
||||
Object.assign(local, v || {})
|
||||
}, { deep: true })
|
||||
|
||||
function onKeyword(e: { value?: string } | string) {
|
||||
const val = typeof e === 'string' ? e : String(e?.value ?? '')
|
||||
emit('update:keyword', val)
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
emit('update:modelValue', { ...local })
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
emit('update:modelValue', { ...local })
|
||||
emit('search')
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
Object.keys(local).forEach(k => { local[k] = '' })
|
||||
emit('update:keyword', '')
|
||||
emit('update:modelValue', {})
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="filter-bar admin-card">
|
||||
<wd-input
|
||||
:model-value="keyword"
|
||||
:placeholder="placeholder"
|
||||
clearable
|
||||
@update:model-value="onKeyword"
|
||||
@confirm="onSearch"
|
||||
/>
|
||||
<view v-for="f in filters.filter(i => i.key !== 'keyword')" :key="f.key" class="filter-row">
|
||||
<text class="label text-art-muted">{{ f.label }}</text>
|
||||
<wd-input
|
||||
v-if="f.type === 'text'"
|
||||
:model-value="String(local[f.key] ?? '')"
|
||||
:placeholder="f.label"
|
||||
clearable
|
||||
@update:model-value="(v: string | number) => { local[f.key] = v; onFilterChange() }"
|
||||
/>
|
||||
<wd-picker
|
||||
v-else-if="f.type === 'select' && f.options"
|
||||
:columns="[f.options.map(o => ({ label: o.label, value: o.value }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { local[f.key] = (value?.[0] as { value?: unknown })?.value ?? value?.[0] ?? ''; onFilterChange() }"
|
||||
>
|
||||
<view class="picker-val">
|
||||
{{ f.options.find(o => String(o.value) === String(local[f.key] ?? ''))?.label || '请选择' }}
|
||||
</view>
|
||||
</wd-picker>
|
||||
</view>
|
||||
<slot />
|
||||
<view class="actions">
|
||||
<wd-button size="small" type="primary" @click="onSearch">搜索</wd-button>
|
||||
<wd-button size="small" plain @click="onReset">重置</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-bar { padding: 20rpx 24rpx; margin-bottom: 20rpx; }
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.label { font-size: 24rpx; min-width: 100rpx; }
|
||||
.picker-val {
|
||||
flex: 1;
|
||||
padding: 16rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgb(var(--art-text));
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台表单字段外壳:wot-ui 2.2 的 wd-input 不渲染 label prop,
|
||||
* 用可见标签行保证暗色下字段含义清晰。
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
label?: string
|
||||
required?: boolean
|
||||
}>(), {
|
||||
label: '',
|
||||
required: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="admin-form-field">
|
||||
<text v-if="label" class="admin-form-field__label">
|
||||
<text v-if="required" class="req">*</text>{{ label }}
|
||||
</text>
|
||||
<slot />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.req {
|
||||
color: rgb(var(--art-error));
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台折线图:旧版 canvas,金色描边,适配暗色工作台。
|
||||
* 多系列时按同色相不同透明度区分;下方 HTML 图例标明发文/UV,避免「UV 没展示」的误解。
|
||||
* 小程序里 v-if 挂载后首帧 boundingClientRect 常为 0,需重试并设置原生宽高。
|
||||
* getCurrentInstance 仅在 setup/同步生命周期有效,必须在此处缓存 proxy。
|
||||
*/
|
||||
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 横轴标签 */
|
||||
categories?: string[]
|
||||
/** 系列:{ name, data } */
|
||||
series?: { name: string, data: number[] }[]
|
||||
height?: number
|
||||
}>(), {
|
||||
categories: () => [],
|
||||
series: () => [],
|
||||
height: 220,
|
||||
})
|
||||
|
||||
const SERIES_COLORS = ['#d4b383', 'rgba(120,180,255,0.9)', 'rgba(160,220,160,0.9)']
|
||||
|
||||
/** setup 阶段同步缓存,供异步 draw 使用 */
|
||||
const compProxy = getCurrentInstance()?.proxy as never
|
||||
const canvasId = `line-${Math.random().toString(36).slice(2, 8)}`
|
||||
/** 原生 canvas 缓冲像素宽(含 dpr),绑定到 width 属性才能在小程序上正常绘制 */
|
||||
const canvasW = ref(300)
|
||||
const canvasH = ref(220)
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let retryCount = 0
|
||||
const MAX_RETRY = 5
|
||||
|
||||
const legendItems = computed(() =>
|
||||
(props.series || []).map((s, i) => ({
|
||||
name: s.name || `系列${i + 1}`,
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length],
|
||||
})),
|
||||
)
|
||||
|
||||
/** 优先新 API,降级 getSystemInfoSync,减少弃用警告 */
|
||||
function fallbackWidth(): number {
|
||||
try {
|
||||
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||||
if (win?.windowWidth) return Math.max(200, win.windowWidth - 48)
|
||||
const sys = uni.getSystemInfoSync()
|
||||
return Math.max(200, (sys.windowWidth || 375) - 48)
|
||||
} catch {
|
||||
return 300
|
||||
}
|
||||
}
|
||||
|
||||
function getDpr(): number {
|
||||
try {
|
||||
const device = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
|
||||
if (device && 'pixelRatio' in device && Number(device.pixelRatio)) {
|
||||
return Number(device.pixelRatio)
|
||||
}
|
||||
return uni.getSystemInfoSync().pixelRatio || 2
|
||||
} catch {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建选择器:有组件实例则限定在组件内,否则页面级查询(canvasId 已全局唯一)。
|
||||
* 禁止对 null 调用 .in(),否则 mp-weixin 读 $scope 直接报错。
|
||||
*/
|
||||
function createQuery() {
|
||||
const q = uni.createSelectorQuery()
|
||||
if (compProxy) return q.in(compProxy)
|
||||
return q
|
||||
}
|
||||
|
||||
/**
|
||||
* 在逻辑像素坐标下绘制折线(调用前需已设好原生宽高并 scale)。
|
||||
*/
|
||||
function paint(ctx: UniApp.CanvasContext, w: number, h: number, dpr: number) {
|
||||
const cats = props.categories
|
||||
const series = props.series
|
||||
ctx.scale(dpr, dpr)
|
||||
const padL = 36
|
||||
const padR = 12
|
||||
const padT = 16
|
||||
const padB = 28
|
||||
const plotW = w - padL - padR
|
||||
const plotH = h - padT - padB
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
const all = series.flatMap(s => s.data)
|
||||
const max = Math.max(1, ...all)
|
||||
const min = Math.min(0, ...all)
|
||||
const span = max - min || 1
|
||||
|
||||
ctx.setStrokeStyle('rgba(255,255,255,0.08)')
|
||||
ctx.setLineWidth(1)
|
||||
for (let i = 0; i <= 3; i++) {
|
||||
const y = padT + (plotH * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padL, y)
|
||||
ctx.lineTo(w - padR, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
series.forEach((s, si) => {
|
||||
const color = SERIES_COLORS[si % SERIES_COLORS.length]
|
||||
ctx.setStrokeStyle(color)
|
||||
ctx.setLineWidth(2)
|
||||
ctx.beginPath()
|
||||
s.data.forEach((v, i) => {
|
||||
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
|
||||
const y = padT + plotH - ((v - min) / span) * plotH
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.stroke()
|
||||
s.data.forEach((v, i) => {
|
||||
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
|
||||
const y = padT + plotH - ((v - min) / span) * plotH
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2)
|
||||
ctx.setFillStyle(color)
|
||||
ctx.fill()
|
||||
})
|
||||
})
|
||||
|
||||
ctx.setFillStyle('rgba(136,136,136,1)')
|
||||
ctx.setFontSize(10)
|
||||
const step = Math.max(1, Math.ceil(cats.length / 5))
|
||||
cats.forEach((c, i) => {
|
||||
if (i % step !== 0 && i !== cats.length - 1) return
|
||||
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
|
||||
ctx.fillText(String(c).slice(-5), x - 12, h - 8)
|
||||
})
|
||||
ctx.draw()
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量并绘制。宽度为 0 时用系统宽度兜底并短延迟重试;
|
||||
* 先写原生宽高再 nextTick 绘制,避免属性未同步导致空白。
|
||||
*/
|
||||
const draw = (isRetry = false) => {
|
||||
const cats = props.categories
|
||||
const series = props.series
|
||||
if (!cats.length || !series.length) return
|
||||
if (!isRetry) retryCount = 0
|
||||
|
||||
createQuery()
|
||||
.select(`#${canvasId}`)
|
||||
.boundingClientRect((rect) => {
|
||||
const box = Array.isArray(rect) ? rect[0] : rect
|
||||
let w = box?.width || 0
|
||||
const measuredOk = !!w
|
||||
if (!w) w = fallbackWidth()
|
||||
if (!measuredOk && retryCount < MAX_RETRY) {
|
||||
retryCount += 1
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = setTimeout(() => draw(true), 80)
|
||||
} else if (measuredOk) {
|
||||
retryCount = 0
|
||||
}
|
||||
if (!w) return
|
||||
|
||||
const h = props.height
|
||||
const dpr = getDpr()
|
||||
canvasW.value = Math.floor(w * dpr)
|
||||
canvasH.value = Math.floor(h * dpr)
|
||||
nextTick(() => {
|
||||
const ctx = uni.createCanvasContext(canvasId, compProxy)
|
||||
paint(ctx, w, h, dpr)
|
||||
})
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
onMounted(() => nextTick(() => draw()))
|
||||
onUnmounted(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
})
|
||||
watch(() => [props.categories, props.series], () => nextTick(() => draw()), { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="line-wrap">
|
||||
<canvas
|
||||
:id="canvasId"
|
||||
:canvas-id="canvasId"
|
||||
class="chart"
|
||||
:width="canvasW"
|
||||
:height="canvasH"
|
||||
:style="{ width: '100%', height: `${height}px` }"
|
||||
/>
|
||||
<view v-if="legendItems.length" class="legend">
|
||||
<view v-for="(item, i) in legendItems" :key="i" class="legend-item">
|
||||
<view class="dot" :style="{ background: item.color }" />
|
||||
<text class="legend-text">{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-wrap { width: 100%; }
|
||||
.chart { width: 100%; display: block; }
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
.legend-text {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 媒体选择器:从相册选图并上传到 /admin/attachments/upload,回写 URL。
|
||||
* 作品封面、头像、附件库等复用,避免各表单重复实现上传。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import { uploadAttachment } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
mediaType?: 'image' | 'video' | 'all'
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
label: '图片',
|
||||
mediaType: 'image',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string]
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const uploading = ref(false)
|
||||
|
||||
const preview = computed(() => resolveMediaUrl(props.modelValue))
|
||||
|
||||
async function choose() {
|
||||
try {
|
||||
if (props.mediaType === 'video') {
|
||||
const res = await uni.chooseVideo({ sourceType: ['album', 'camera'], compressed: true })
|
||||
await doUpload(res.tempFilePath)
|
||||
return
|
||||
}
|
||||
const res = await uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] })
|
||||
const path = res.tempFilePaths?.[0]
|
||||
if (!path) return
|
||||
await doUpload(path)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
// 用户取消不提示
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '选择失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpload(filePath: string) {
|
||||
uploading.value = true
|
||||
try {
|
||||
const result = await uploadAttachment(filePath)
|
||||
const url = String(result.fileUrl || result.url || '')
|
||||
if (!url) throw new Error('上传成功但未返回地址')
|
||||
emit('update:modelValue', url)
|
||||
toast.success('上传成功')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="media-picker">
|
||||
<text v-if="label" class="label text-art-muted">{{ label }}</text>
|
||||
<view class="box" @click="choose">
|
||||
<image v-if="preview && mediaType !== 'video'" class="preview" :src="preview" mode="aspectFill" />
|
||||
<view v-else-if="preview && mediaType === 'video'" class="video-hint">
|
||||
<text>已选视频</text>
|
||||
<text class="url text-art-muted">{{ modelValue }}</text>
|
||||
</view>
|
||||
<view v-else class="placeholder">
|
||||
<wd-loading v-if="uploading" />
|
||||
<text v-else class="text-art-muted">点击上传{{ label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button v-if="modelValue" size="small" plain @click.stop="clear">清除</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media-picker { display: flex; flex-direction: column; gap: 12rpx; margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.box {
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview { width: 100%; height: 100%; }
|
||||
.placeholder, .video-hint {
|
||||
padding: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.url { font-size: 20rpx; word-break: break-all; max-width: 200rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 状态徽章:展示发布/启用/咨询状态,可点击切换(由父组件处理具体 API)。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value?: string | number | boolean | null
|
||||
map?: Record<string, string>
|
||||
clickable?: boolean
|
||||
}>(), {
|
||||
value: '',
|
||||
map: () => ({}),
|
||||
clickable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const label = computed(() => {
|
||||
const key = String(props.value ?? '')
|
||||
if (props.map[key]) return props.map[key]
|
||||
if (props.value === true || props.value === 1 || props.value === '1') return '是'
|
||||
if (props.value === false || props.value === 0 || props.value === '0') return '否'
|
||||
return key || '—'
|
||||
})
|
||||
|
||||
const tone = computed(() => {
|
||||
const v = props.value
|
||||
if (v === true || v === 1 || v === '1' || v === 2 || v === '2') return 'on'
|
||||
if (v === false || v === 0 || v === '0') return 'off'
|
||||
return 'muted'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="badge"
|
||||
:class="[tone, { clickable }]"
|
||||
@click.stop="clickable && emit('click')"
|
||||
>
|
||||
{{ label }}
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.on { background: rgba(212, 179, 131, 0.2); color: rgb(var(--art-accent)); }
|
||||
.off { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.45); }
|
||||
.muted { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.55); }
|
||||
.clickable { border: 1px solid rgba(212, 179, 131, 0.35); }
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 后台页鉴权守卫:未登录或无员工角色则回我的页。
|
||||
*/
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
export function useAdminGuard() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
|
||||
function guardAdmin(): boolean {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return { guardAdmin, isAuthenticated, canEnterAdmin }
|
||||
}
|
||||
380
blog-wot-uniapp/src/subPackages/admin/config/resourceSchemas.ts
Normal file
380
blog-wot-uniapp/src/subPackages/admin/config/resourceSchemas.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 简单资源 Schema 注册表:驱动通用 list/form,消灭默认 4 字段 + JSON 主路径。
|
||||
* 复杂模块(posts/works/videos/attachments/settings/about/inquiries)走专用页,不在此注册。
|
||||
*/
|
||||
|
||||
export type FieldType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'switch'
|
||||
| 'select'
|
||||
| 'media'
|
||||
| 'rating'
|
||||
|
||||
export interface SchemaField {
|
||||
key: string
|
||||
label: string
|
||||
type: FieldType
|
||||
placeholder?: string
|
||||
/** select 选项 */
|
||||
options?: { label: string, value: string | number }[]
|
||||
/** 只读(如系统模板 slug) */
|
||||
readonly?: boolean
|
||||
/** 编辑态才显示 */
|
||||
editOnly?: boolean
|
||||
}
|
||||
|
||||
export interface SchemaFilter {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'select'
|
||||
options?: { label: string, value: string | number }[]
|
||||
}
|
||||
|
||||
export interface ResourceSchema {
|
||||
title: string
|
||||
/** 真只读:无新建/删除/编辑,点行开详情抽屉 */
|
||||
readonly?: boolean
|
||||
/** 禁止编辑(可新建删除),如邮箱后缀 */
|
||||
noEdit?: boolean
|
||||
/** 页内新建(邮箱后缀) */
|
||||
inlineCreate?: boolean
|
||||
/** 服务端分页 */
|
||||
serverPaging?: boolean
|
||||
/** 默认 pageSize */
|
||||
pageSize?: number
|
||||
/** 列表主标题字段候选 */
|
||||
titleKeys?: string[]
|
||||
/** 列表副标题字段候选 */
|
||||
subtitleKeys?: string[]
|
||||
/** 封面/头像字段 */
|
||||
coverKey?: string
|
||||
/** 状态字段(用于徽章) */
|
||||
statusKey?: string
|
||||
/** 状态文案映射 */
|
||||
statusMap?: Record<string, string>
|
||||
filters?: SchemaFilter[]
|
||||
fields: SchemaField[]
|
||||
/** 本地关键词过滤字段 */
|
||||
localSearchKeys?: string[]
|
||||
}
|
||||
|
||||
const CODE_TYPE_OPTIONS = [
|
||||
{ label: '前端', value: 0 },
|
||||
{ label: '后端', value: 1 },
|
||||
{ label: '其他', value: 2 },
|
||||
]
|
||||
|
||||
/** 简单资源 Schema;未列出的走兜底 */
|
||||
export const RESOURCE_SCHEMAS: Record<string, ResourceSchema> = {
|
||||
categories: {
|
||||
title: '分类管理',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug', 'sortOrder'],
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
columns: {
|
||||
title: '专栏管理',
|
||||
titleKeys: ['name', 'title'],
|
||||
subtitleKeys: ['description', 'sortOrder'],
|
||||
coverKey: 'cover',
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
filters: [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
localSearchKeys: ['name', 'title', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'cover', label: '封面', type: 'media' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'video-categories': {
|
||||
title: '视频分类',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug', 'sortOrder'],
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'video-albums': {
|
||||
title: '视频专辑',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['categoryName', 'videoCount'],
|
||||
coverKey: 'cover',
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
localSearchKeys: ['name', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'categoryId', label: '分类ID', type: 'number' },
|
||||
{ key: 'cover', label: '封面', type: 'media' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
snippets: {
|
||||
title: '代码片段',
|
||||
titleKeys: ['title'],
|
||||
subtitleKeys: ['description'],
|
||||
localSearchKeys: ['title', 'description'],
|
||||
fields: [
|
||||
{ key: 'title', label: '标题', type: 'text' },
|
||||
{ key: 'codeTypeId', label: '分类ID', type: 'number' },
|
||||
{ key: 'code', label: '代码', type: 'textarea' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'code-types': {
|
||||
title: '代码分类',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['category'],
|
||||
localSearchKeys: ['name'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'category', label: '类别', type: 'select', options: CODE_TYPE_OPTIONS },
|
||||
],
|
||||
},
|
||||
tags: {
|
||||
title: '标签管理',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug'],
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
],
|
||||
},
|
||||
testimonials: {
|
||||
title: '客户评价',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['role', 'rating'],
|
||||
coverKey: 'avatar',
|
||||
filters: [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' },
|
||||
{
|
||||
key: 'rating',
|
||||
label: '评分',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '5星', value: 5 },
|
||||
{ label: '4星', value: 4 },
|
||||
{ label: '3星', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
localSearchKeys: ['name', 'role', 'content'],
|
||||
fields: [
|
||||
{ key: 'name', label: '姓名', type: 'text' },
|
||||
{ key: 'role', label: '职位', type: 'text' },
|
||||
{ key: 'avatar', label: '头像', type: 'media' },
|
||||
{ key: 'rating', label: '评分', type: 'rating' },
|
||||
{ key: 'content', label: '评价内容', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
partners: {
|
||||
title: '合作伙伴',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['url', 'description'],
|
||||
coverKey: 'logo',
|
||||
localSearchKeys: ['name', 'description', 'url'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'logo', label: 'Logo', type: 'media' },
|
||||
{ key: 'url', label: '链接', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'email-suffixes': {
|
||||
title: '邮箱配置',
|
||||
noEdit: true,
|
||||
inlineCreate: true,
|
||||
titleKeys: ['suffix'],
|
||||
subtitleKeys: ['sortOrder', 'isActive'],
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
localSearchKeys: ['suffix'],
|
||||
fields: [
|
||||
{ key: 'suffix', label: '后缀', type: 'text', placeholder: '@example.com' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
],
|
||||
},
|
||||
users: {
|
||||
title: '用户管理',
|
||||
serverPaging: true,
|
||||
pageSize: 10,
|
||||
titleKeys: ['username'],
|
||||
subtitleKeys: ['email', 'role'],
|
||||
coverKey: 'avatar',
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
filters: [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' },
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'admin', value: 'admin' },
|
||||
{ label: 'editor', value: 'editor' },
|
||||
{ label: 'viewer', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{ key: 'username', label: '用户名', type: 'text' },
|
||||
{ key: 'email', label: '邮箱', type: 'text' },
|
||||
{ key: 'avatar', label: '头像', type: 'media' },
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'admin', value: 'admin' },
|
||||
{ label: 'editor', value: 'editor' },
|
||||
{ label: 'viewer', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'bio', label: '简介', type: 'textarea' },
|
||||
{ key: 'phone', label: '手机', type: 'text' },
|
||||
{ key: 'wechat', label: '微信', type: 'text' },
|
||||
{ key: 'wechatQrcode', label: '微信二维码', type: 'media' },
|
||||
],
|
||||
},
|
||||
roles: {
|
||||
title: '角色管理',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['description'],
|
||||
localSearchKeys: ['name', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'attachment-categories': {
|
||||
title: '附件分类',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['sortOrder', 'description'],
|
||||
localSearchKeys: ['name', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'ppt-templates': {
|
||||
title: 'PPT 模板',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug', 'sortOrder'],
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
{ key: 'isDefault', label: '默认', type: 'switch' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'config', label: '配置 JSON', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
logs: {
|
||||
title: '操作日志',
|
||||
readonly: true,
|
||||
serverPaging: true,
|
||||
pageSize: 20,
|
||||
titleKeys: ['action', 'path'],
|
||||
subtitleKeys: ['username', 'createdAt', 'method', 'status'],
|
||||
filters: [
|
||||
{ key: 'action', label: '操作', type: 'text' },
|
||||
{ key: 'method', label: '方法', type: 'text' },
|
||||
{ key: 'status', label: '状态码', type: 'text' },
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
'access-logs': {
|
||||
title: '访问日志',
|
||||
readonly: true,
|
||||
serverPaging: true,
|
||||
pageSize: 20,
|
||||
titleKeys: ['path'],
|
||||
subtitleKeys: ['region', 'ip', 'createdAt', 'statusCode'],
|
||||
filters: [
|
||||
{ key: 'path', label: '路径', type: 'text' },
|
||||
{ key: 'region', label: '地区', type: 'text' },
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
/** 已拆专用页,保留兜底标题 */
|
||||
posts: { title: '文章管理', fields: [], titleKeys: ['title'] },
|
||||
works: { title: '作品管理', fields: [], titleKeys: ['title'] },
|
||||
videos: { title: '视频列表', fields: [], titleKeys: ['title'] },
|
||||
attachments: { title: '附件库', readonly: true, fields: [], titleKeys: ['originalName', 'fileName'] },
|
||||
inquiries: { title: '合作咨询', readonly: true, fields: [], titleKeys: ['name'] },
|
||||
'oss-configs': { title: 'OSS配置', fields: [], titleKeys: ['name'] },
|
||||
about: { title: '关于页面', fields: [] },
|
||||
settings: { title: '全局配置', fields: [] },
|
||||
}
|
||||
|
||||
export function getResourceSchema(resource: string): ResourceSchema {
|
||||
return RESOURCE_SCHEMAS[resource] || {
|
||||
title: resource || '资源管理',
|
||||
titleKeys: ['title', 'name', 'username'],
|
||||
subtitleKeys: ['description', 'createdAt'],
|
||||
fields: [
|
||||
{ key: 'title', label: '标题', type: 'text' },
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 从记录中按候选 key 取展示文案 */
|
||||
export function pickField(item: Record<string, unknown>, keys?: string[]): string {
|
||||
if (!keys?.length) return ''
|
||||
for (const k of keys) {
|
||||
const v = item[k]
|
||||
if (v != null && v !== '') return String(v)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 全局配置 Schema(自 Web client settingsSchema 精简移植,供小程序设置页分 Tab 编辑)
|
||||
*/
|
||||
|
||||
export type SettingFieldType = 'text' | 'textarea' | 'number' | 'menu-checkboxes' | 'email' | 'homepage-json'
|
||||
|
||||
export interface SettingSchemaItem {
|
||||
key: string
|
||||
label: string
|
||||
type: SettingFieldType
|
||||
group: 'site' | 'seo' | 'navigation' | 'homepage' | 'pagination' | 'contact'
|
||||
default: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const SETTING_GROUPS: { id: SettingSchemaItem['group'], label: string }[] = [
|
||||
{ id: 'site', label: '站点信息' },
|
||||
{ id: 'seo', label: 'SEO' },
|
||||
{ id: 'navigation', label: '导航菜单' },
|
||||
{ id: 'homepage', label: '首页内容' },
|
||||
{ id: 'pagination', label: '分页设置' },
|
||||
{ id: 'contact', label: '联系方式' },
|
||||
]
|
||||
|
||||
export const MENU_OPTIONS = [
|
||||
{ key: 'home', label: '首页' },
|
||||
{ key: 'blog', label: '思考' },
|
||||
{ key: 'columns', label: '专栏' },
|
||||
{ key: 'works', label: '作品' },
|
||||
{ key: 'videos', label: '视频' },
|
||||
{ key: 'snippets', label: '代码' },
|
||||
{ key: 'about', label: '关于' },
|
||||
{ key: 'services', label: '合作' },
|
||||
]
|
||||
|
||||
export const SETTINGS_SCHEMA: SettingSchemaItem[] = [
|
||||
{ key: 'site_title', label: '网站标题', type: 'text', group: 'site', default: '年糕崽崽.Dev', description: '站点名称' },
|
||||
{ key: 'site_author', label: '网站作者', type: 'text', group: 'site', default: '', description: '作者名' },
|
||||
{ key: 'site_description', label: '网站描述', type: 'textarea', group: 'seo', default: '', description: 'SEO 描述' },
|
||||
{ key: 'site_keywords', label: '网站关键词', type: 'textarea', group: 'seo', default: '', description: '逗号分隔' },
|
||||
{
|
||||
key: 'visible_menus',
|
||||
label: '前台菜单',
|
||||
type: 'menu-checkboxes',
|
||||
group: 'navigation',
|
||||
default: '["home","blog","columns","works","videos","snippets","about","services"]',
|
||||
description: '控制前台导航显示',
|
||||
},
|
||||
{
|
||||
key: 'homepage_config',
|
||||
label: '首页配置 JSON',
|
||||
type: 'homepage-json',
|
||||
group: 'homepage',
|
||||
default: '{}',
|
||||
description: 'Hero / Bento 等首页结构,小程序端用 JSON 编辑',
|
||||
},
|
||||
{ key: 'posts_per_page', label: '文章每页', type: 'number', group: 'pagination', default: '10', description: '' },
|
||||
{ key: 'works_per_page', label: '作品每页', type: 'number', group: 'pagination', default: '12', description: '' },
|
||||
{ key: 'snippets_per_page', label: '代码每页', type: 'number', group: 'pagination', default: '12', description: '' },
|
||||
{ key: 'footer_icp', label: 'ICP 备案号', type: 'text', group: 'contact', default: '', description: '' },
|
||||
{ key: 'contact_email', label: '联系邮箱', type: 'email', group: 'contact', default: '', description: '' },
|
||||
]
|
||||
185
blog-wot-uniapp/src/subPackages/admin/pages/about/index.vue
Normal file
185
blog-wot-uniapp/src/subPackages/admin/pages/about/index.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 关于页面单页编辑:结构化字段 + 经历动态增删。
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminList, adminUpdate, normalizeAdminList, type AboutExperience, type AboutProfile } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const profileId = ref<number | ''>('')
|
||||
const techInput = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
avatar: '',
|
||||
location: '',
|
||||
email: '',
|
||||
wechat: '',
|
||||
bio: '',
|
||||
isPrimary: 1,
|
||||
})
|
||||
const techStack = ref<string[]>([])
|
||||
const experiences = ref<AboutExperience[]>([])
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const list = normalizeAdminList(await adminList('about')) as AboutProfile[]
|
||||
const first = list[0]
|
||||
if (first) {
|
||||
profileId.value = Number(first.id) || ''
|
||||
form.name = String(first.name || '')
|
||||
form.avatar = String(first.avatar || '')
|
||||
form.location = String(first.location || '')
|
||||
form.email = String(first.email || '')
|
||||
form.wechat = String(first.wechat || '')
|
||||
form.bio = String(first.bio || '')
|
||||
form.isPrimary = first.isPrimary === 0 || first.isPrimary === false ? 0 : 1
|
||||
techStack.value = Array.isArray(first.techStack) ? [...first.techStack] : []
|
||||
experiences.value = Array.isArray(first.experiences) ? [...first.experiences] : []
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function addTech() {
|
||||
const t = techInput.value.trim()
|
||||
if (!t) return
|
||||
techStack.value.push(t)
|
||||
techInput.value = ''
|
||||
}
|
||||
|
||||
function addExp() {
|
||||
experiences.value.push({ year: '', role: '', company: '' })
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
techStack: techStack.value,
|
||||
experiences: experiences.value,
|
||||
}
|
||||
if (profileId.value) await adminUpdate('about', profileId.value, payload)
|
||||
else {
|
||||
const created = await adminCreate('about', payload) as AboutProfile
|
||||
if (created?.id) profileId.value = Number(created.id)
|
||||
}
|
||||
toast.success('已保存')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="关于页面" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<AdminFormField label="姓名" required>
|
||||
<wd-input v-model="form.name" placeholder="姓名" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.avatar" label="头像" />
|
||||
<AdminFormField label="地点">
|
||||
<wd-input v-model="form.location" placeholder="地点" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="邮箱">
|
||||
<wd-input v-model="form.email" placeholder="邮箱" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="微信">
|
||||
<wd-input v-model="form.wechat" placeholder="微信" clearable />
|
||||
</AdminFormField>
|
||||
<view class="switch-row">
|
||||
<text>主档案</text>
|
||||
<wd-switch :model-value="!!form.isPrimary" @change="(v: boolean) => form.isPrimary = v ? 1 : 0" />
|
||||
</view>
|
||||
<AdminFormField label="简介">
|
||||
<wd-textarea v-model="form.bio" placeholder="简介" />
|
||||
</AdminFormField>
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">技术栈</text>
|
||||
<view class="tags">
|
||||
<view v-for="(t, i) in techStack" :key="i" class="tag" @click="techStack.splice(i, 1)">{{ t }} ×</view>
|
||||
</view>
|
||||
<view class="row-input">
|
||||
<wd-input v-model="techInput" placeholder="添加技术" clearable />
|
||||
<wd-button size="small" @click="addTech">添加</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="section">
|
||||
<view class="sec-head">
|
||||
<text class="label text-art-muted">工作经历</text>
|
||||
<wd-button size="small" plain @click="addExp">添加</wd-button>
|
||||
</view>
|
||||
<view v-for="(exp, i) in experiences" :key="i" class="exp">
|
||||
<AdminFormField label="年份">
|
||||
<wd-input v-model="exp.year" placeholder="年份" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="职位">
|
||||
<wd-input v-model="exp.role" placeholder="职位" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="公司">
|
||||
<wd-input v-model="exp.company" placeholder="公司" clearable />
|
||||
</AdminFormField>
|
||||
<wd-button size="small" type="warning" plain @click="experiences.splice(i, 1)">删除</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
}
|
||||
.section { margin: 20rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 10rpx; margin: 12rpx 0; }
|
||||
.tag {
|
||||
padding: 6rpx 14rpx; border-radius: 8rpx; font-size: 22rpx;
|
||||
background: rgba(212, 179, 131, 0.2); color: rgb(var(--art-accent));
|
||||
}
|
||||
.row-input { display: flex; gap: 12rpx; align-items: center; }
|
||||
.sec-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
|
||||
.exp {
|
||||
padding: 16rpx; margin-bottom: 16rpx; border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
</style>
|
||||
161
blog-wot-uniapp/src/subPackages/admin/pages/analytics/index.vue
Normal file
161
blog-wot-uniapp/src/subPackages/admin/pages/analytics/index.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 数据分析:日期预设 + 真实 stats API + canvas 图表 + 地区 TOP 列表。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminLineChart from '../../components/AdminLineChart.vue'
|
||||
import AdminBarChart from '../../components/AdminBarChart.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { getDashboardStats, trendPointValue, type DashboardStats, type TrendPoint } from '@/api'
|
||||
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const stats = ref<DashboardStats>({})
|
||||
const preset = ref<'today' | '7d' | '30d'>('7d')
|
||||
|
||||
function formatDate(d: Date) {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
function rangeOf(p: typeof preset.value) {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
if (p === 'today') { /* same day */ }
|
||||
else if (p === '7d') start.setDate(end.getDate() - 6)
|
||||
else start.setDate(end.getDate() - 29)
|
||||
return { startDate: formatDate(start), endDate: formatDate(end) }
|
||||
}
|
||||
|
||||
const kpi = computed(() => [
|
||||
{ label: '文章', value: stats.value.posts ?? 0 },
|
||||
{ label: '作品', value: stats.value.works ?? 0 },
|
||||
{ label: '咨询', value: stats.value.inquiryCount ?? 0 },
|
||||
{
|
||||
label: '区间 UV',
|
||||
value: (stats.value.uvTrend || []).reduce((s, i) => s + trendPointValue(i), 0),
|
||||
},
|
||||
])
|
||||
|
||||
const cats = computed(() => {
|
||||
const set = new Set<string>()
|
||||
;(stats.value.postsTrend || []).forEach(p => set.add(p.date))
|
||||
;(stats.value.uvTrend || []).forEach(p => set.add(p.date))
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
function mapByDate(list: TrendPoint[] | undefined, categories: string[]) {
|
||||
const map = new Map((list || []).map(i => [i.date, trendPointValue(i)]))
|
||||
return categories.map(d => map.get(d) ?? 0)
|
||||
}
|
||||
|
||||
const lineSeries = computed(() => [
|
||||
{ name: '发文', data: mapByDate(stats.value.postsTrend, cats.value) },
|
||||
{ name: 'UV', data: mapByDate(stats.value.uvTrend, cats.value) },
|
||||
])
|
||||
|
||||
const barCategories = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => String(p.title || '').slice(0, 6) || '—'),
|
||||
)
|
||||
const barValues = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => Number(p.count ?? p.readCount ?? 0)),
|
||||
)
|
||||
|
||||
const regions = computed(() => {
|
||||
const list = stats.value.userRegions || []
|
||||
return [...list]
|
||||
.map(r => ({
|
||||
name: String(r.region || r.name || '未知'),
|
||||
value: Number(r.count ?? r.value ?? 0),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
stats.value = await getDashboardStats(rangeOf(preset.value))
|
||||
}
|
||||
catch {
|
||||
stats.value = {}
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setPreset(p: typeof preset.value) {
|
||||
preset.value = p
|
||||
load()
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="数据分析" />
|
||||
<view class="page-pad">
|
||||
<view class="presets">
|
||||
<view class="chip" :class="{ on: preset === 'today' }" @click="setPreset('today')">今日</view>
|
||||
<view class="chip" :class="{ on: preset === '7d' }" @click="setPreset('7d')">近7天</view>
|
||||
<view class="chip" :class="{ on: preset === '30d' }" @click="setPreset('30d')">近30天</view>
|
||||
</view>
|
||||
<view class="grid">
|
||||
<view v-for="item in kpi" :key="item.label" class="admin-card card">
|
||||
<text class="font-mono-label text-art-muted">{{ item.label }}</text>
|
||||
<text class="value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">发文 / UV 趋势</text>
|
||||
<AdminLineChart v-if="cats.length" :categories="cats" :series="lineSeries" />
|
||||
<wd-empty v-else description="暂无趋势" />
|
||||
</view>
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">热门文章</text>
|
||||
<AdminBarChart v-if="barCategories.length" :categories="barCategories" :values="barValues" />
|
||||
<wd-empty v-else description="暂无热门" />
|
||||
</view>
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">访问地区 TOP</text>
|
||||
<view v-for="(r, i) in regions" :key="i" class="region">
|
||||
<text class="r-name">{{ i + 1 }}. {{ r.name }}</text>
|
||||
<text class="r-val text-art-accent">{{ r.value }}</text>
|
||||
</view>
|
||||
<wd-empty v-if="!regions.length" description="暂无地区数据" />
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.presets { display: flex; gap: 12rpx; margin-bottom: 20rpx; }
|
||||
.chip {
|
||||
padding: 10rpx 22rpx; border-radius: 8rpx; font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.chip.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
.card { padding: 24rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.value { font-size: 40rpx; color: rgb(var(--art-accent)); font-family: monospace; }
|
||||
.chart-card { padding: 24rpx; margin-bottom: 20rpx; }
|
||||
.chart-title { display: block; font-size: 28rpx; margin-bottom: 16rpx; color: rgb(var(--art-text)); }
|
||||
.region {
|
||||
display: flex; justify-content: space-between; padding: 14rpx 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.r-name { color: rgb(var(--art-text)); font-size: 26rpx; }
|
||||
.r-val { font-family: monospace; }
|
||||
</style>
|
||||
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 附件库:小红书式双列瀑布流 + 分页上拉 + page-container 详情。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminDelete,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
normalizeAdminList,
|
||||
normalizePagination,
|
||||
uploadAttachment,
|
||||
type Attachment,
|
||||
} from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const list = ref<Attachment[]>([])
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
const detailShow = ref(false)
|
||||
const current = ref<Attachment | null>(null)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'fileType',
|
||||
label: '类型',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '文档', value: 'document' },
|
||||
{ label: '其他', value: 'other' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 奇偶分列,形成简易瀑布流 */
|
||||
const leftCol = computed(() => list.value.filter((_, i) => i % 2 === 0))
|
||||
const rightCol = computed(() => list.value.filter((_, i) => i % 2 === 1))
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
if (finished.value && !reset) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (reset || !categories.value.length) {
|
||||
categories.value = normalizeAdminList(await adminList('attachment-categories')) as { id: number, name: string }[]
|
||||
}
|
||||
const data = await adminList('attachments', {
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})
|
||||
const p = normalizePagination<Attachment>(data, page.value, 20)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
finished.value = list.value.length >= p.total || p.list.length < 20
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function fileUrl(item: Attachment) {
|
||||
return resolveMediaUrl(item.fileUrl || item.url || '')
|
||||
}
|
||||
|
||||
function isImage(item: Attachment) {
|
||||
const t = String(item.fileType || item.fileUrl || item.url || '').toLowerCase()
|
||||
return t.includes('image') || /\.(png|jpe?g|gif|webp|svg)$/i.test(t)
|
||||
}
|
||||
|
||||
function isVideo(item: Attachment) {
|
||||
const t = String(item.fileType || item.fileUrl || item.url || '').toLowerCase()
|
||||
return t.includes('video') || /\.(mp4|mov|webm|m4v)$/i.test(t)
|
||||
}
|
||||
|
||||
function openDetail(item: Attachment) {
|
||||
current.value = { ...item }
|
||||
detailShow.value = true
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
try {
|
||||
const res = await uni.chooseImage({ count: 9, sizeType: ['compressed'] })
|
||||
uploading.value = true
|
||||
for (const path of res.tempFilePaths || []) {
|
||||
await uploadAttachment(path)
|
||||
}
|
||||
toast.success('上传完成')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyUrl() {
|
||||
const url = fileUrl(current.value || { id: 0 })
|
||||
if (!url) return
|
||||
uni.setClipboardData({ data: url, success: () => toast.success('已复制') })
|
||||
}
|
||||
|
||||
async function saveCategory(catId: number) {
|
||||
if (!current.value?.id) return
|
||||
try {
|
||||
await adminUpdate('attachments', current.value.id, { categoryId: catId })
|
||||
current.value.categoryId = catId
|
||||
toast.success('已更新分类')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!current.value?.id) return
|
||||
dialog.confirm({ title: '确认删除', msg: '确定删除该附件吗?' }).then(async () => {
|
||||
try {
|
||||
await adminDelete('attachments', current.value!.id)
|
||||
toast.success('已删除')
|
||||
detailShow.value = false
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const detailRows = () => {
|
||||
const c = current.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '名称', value: String(c.originalName || c.fileName || c.id) },
|
||||
{ label: '类型', value: String(c.fileType || '') },
|
||||
{ label: '大小', value: c.fileSize ? `${Math.round(Number(c.fileSize) / 1024)} KB` : '' },
|
||||
{ label: 'URL', value: fileUrl(c) },
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="附件库" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
<!-- 双列瀑布:奇偶分列,避免 CSS grid min-width 撑破 -->
|
||||
<view class="waterfall">
|
||||
<view class="col">
|
||||
<view
|
||||
v-for="item in leftCol"
|
||||
:key="item.id"
|
||||
class="card"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<view class="media">
|
||||
<image
|
||||
v-if="isImage(item)"
|
||||
class="thumb"
|
||||
:src="fileUrl(item)"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-else class="file-icon">
|
||||
<wd-icon :name="isVideo(item) ? 'video' : 'file'" size="28px" />
|
||||
<text v-if="isVideo(item)" class="badge">视频</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="name">{{ item.originalName || item.fileName || item.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="col">
|
||||
<view
|
||||
v-for="item in rightCol"
|
||||
:key="item.id"
|
||||
class="card"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<view class="media">
|
||||
<image
|
||||
v-if="isImage(item)"
|
||||
class="thumb"
|
||||
:src="fileUrl(item)"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-else class="file-icon">
|
||||
<wd-icon :name="isVideo(item) ? 'video' : 'file'" size="28px" />
|
||||
<text v-if="isVideo(item)" class="badge">视频</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="name">{{ item.originalName || item.fileName || item.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading || uploading" />
|
||||
<text v-if="!finished && list.length" class="more text-art-muted">上拉加载更多</text>
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无附件" />
|
||||
</view>
|
||||
<view class="fab" @click="onUpload"><wd-icon name="upload" size="24px" color="#fff" /></view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="detailShow" title="附件详情" :rows="detailRows()">
|
||||
<view class="cat-section">
|
||||
<text class="text-art-muted">改分类</text>
|
||||
<view class="tags">
|
||||
<view
|
||||
v-for="c in categories"
|
||||
:key="c.id"
|
||||
class="tag"
|
||||
:class="{ on: current?.categoryId === c.id }"
|
||||
@click="saveCategory(c.id)"
|
||||
>
|
||||
{{ c.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<template #footer>
|
||||
<wd-button size="small" @click="copyUrl">复制 URL</wd-button>
|
||||
<wd-button size="small" type="warning" @click="onDelete">删除</wd-button>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad {
|
||||
padding: 24rpx 24rpx 120rpx;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.waterfall {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.card {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 16rpx;
|
||||
background: rgb(var(--art-admin-card));
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.media {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 180rpx;
|
||||
max-height: 480rpx;
|
||||
}
|
||||
.file-icon {
|
||||
width: 100%;
|
||||
height: 220rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.badge {
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(212, 179, 131, 0.25);
|
||||
color: rgb(var(--art-accent));
|
||||
}
|
||||
.name {
|
||||
display: block;
|
||||
padding: 16rpx 18rpx 20rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.more { display: block; text-align: center; font-size: 22rpx; padding: 20rpx; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.cat-section { margin-top: 16rpx; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
195
blog-wot-uniapp/src/subPackages/admin/pages/columns/form.vue
Normal file
195
blog-wot-uniapp/src/subPackages/admin/pages/columns/form.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 专栏表单:封面/启用 + 编辑态挂载文章(add/remove)。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
addPostToColumn,
|
||||
adminCreate,
|
||||
adminGet,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
getAdminColumnPosts,
|
||||
normalizeAdminList,
|
||||
removePostFromColumn,
|
||||
type Column,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const linkedPosts = ref<Post[]>([])
|
||||
const allPosts = ref<Post[]>([])
|
||||
const postKeyword = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
cover: '',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
isActive: 1,
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建专栏' : '编辑专栏'))
|
||||
const filteredPosts = computed(() => {
|
||||
const kw = postKeyword.value.trim().toLowerCase()
|
||||
const linkedIds = new Set(linkedPosts.value.map(p => Number(p.id)))
|
||||
return allPosts.value.filter((p) => {
|
||||
if (linkedIds.has(Number(p.id))) return false
|
||||
if (!kw) return true
|
||||
return String(p.title || '').toLowerCase().includes(kw)
|
||||
}).slice(0, 30)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const postData = await adminList('posts', { page: 1, pageSize: 200 })
|
||||
allPosts.value = normalizeAdminList(postData) as Post[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('columns', id.value) as Column
|
||||
form.name = String(data.name || data.title || '')
|
||||
form.cover = String(data.cover || '')
|
||||
form.description = String(data.description || '')
|
||||
form.sortOrder = Number(data.sortOrder || 0)
|
||||
form.isActive = data.isActive === 0 || data.isActive === false ? 0 : 1
|
||||
linkedPosts.value = await getAdminColumnPosts(id.value)
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.name.trim()) {
|
||||
toast.show('请填写名称')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
await adminUpdate('columns', id.value, payload)
|
||||
toast.success('已保存')
|
||||
}
|
||||
else {
|
||||
const created = await adminCreate('columns', payload) as Column
|
||||
if (created?.id) id.value = String(created.id)
|
||||
mode.value = 'edit'
|
||||
toast.success('已创建,可继续挂载文章')
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addPost(post: Post) {
|
||||
if (!id.value) {
|
||||
toast.show('请先保存专栏')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addPostToColumn(id.value, post.id)
|
||||
linkedPosts.value.push(post)
|
||||
toast.success('已添加')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removePost(post: Post) {
|
||||
if (!id.value) return
|
||||
try {
|
||||
await removePostFromColumn(id.value, post.id)
|
||||
linkedPosts.value = linkedPosts.value.filter(p => Number(p.id) !== Number(post.id))
|
||||
toast.success('已移除')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '移除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.name" label="名称" clearable />
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<wd-input v-model="form.sortOrder" label="排序" type="number" />
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean | { value: boolean }) => form.isActive = (typeof v === 'boolean' ? v : v.value) ? 1 : 0" />
|
||||
</view>
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view v-if="mode === 'edit' && id" class="admin-card form-card" style="margin-top: 20rpx;">
|
||||
<text class="section-title" style="font-size: 30rpx;">专栏文章</text>
|
||||
<view v-for="p in linkedPosts" :key="String(p.id)" class="post-row">
|
||||
<text class="ptitle">{{ p.title }}</text>
|
||||
<wd-button size="small" type="warning" plain @click="removePost(p)">移除</wd-button>
|
||||
</view>
|
||||
<wd-input v-model="postKeyword" label="搜索添加" placeholder="文章标题" clearable />
|
||||
<view v-for="p in filteredPosts" :key="`a-${p.id}`" class="post-row">
|
||||
<text class="ptitle">{{ p.title }}</text>
|
||||
<wd-button size="small" @click="addPost(p)">添加</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
}
|
||||
.post-row {
|
||||
display: flex; align-items: center; gap: 12rpx;
|
||||
padding: 16rpx 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.ptitle {
|
||||
flex: 1; font-size: 26rpx; color: rgb(var(--art-text));
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
228
blog-wot-uniapp/src/subPackages/admin/pages/dashboard/index.vue
Normal file
228
blog-wot-uniapp/src/subPackages/admin/pages/dashboard/index.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台工作台:KPI 可跳转 + 最近文章 + 最近操作(查看全部进日志页)。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminLineChart from '../../components/AdminLineChart.vue'
|
||||
import AdminBarChart from '../../components/AdminBarChart.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import {
|
||||
adminList,
|
||||
getDashboardStats,
|
||||
getOperationLogs,
|
||||
normalizeAdminList,
|
||||
trendPointValue,
|
||||
type DashboardStats,
|
||||
type Post,
|
||||
type TrendPoint,
|
||||
} from '@/api'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
const loading = ref(false)
|
||||
const stats = ref<DashboardStats>({})
|
||||
const logs = ref<unknown[]>([])
|
||||
const recentPosts = ref<Post[]>([])
|
||||
|
||||
const todayUv = computed(() => {
|
||||
const list = stats.value.uvTrend || []
|
||||
if (!list.length) return 0
|
||||
return trendPointValue(list[list.length - 1])
|
||||
})
|
||||
|
||||
const kpiList = computed(() => [
|
||||
{ key: 'posts', label: '文章', value: stats.value.posts ?? 0, url: '/subPackages/admin/pages/posts/list' },
|
||||
{ key: 'works', label: '作品', value: stats.value.works ?? 0, url: '/subPackages/admin/pages/works/list' },
|
||||
{ key: 'inquiry', label: '咨询', value: stats.value.inquiryCount ?? 0, url: '/subPackages/admin/pages/inquiries/list' },
|
||||
{ key: 'uv', label: '今日 UV', value: todayUv.value, url: '/subPackages/admin/pages/analytics/index' },
|
||||
])
|
||||
|
||||
const trendCategories = computed(() => {
|
||||
const posts = stats.value.postsTrend || []
|
||||
const uv = stats.value.uvTrend || []
|
||||
const set = new Set<string>()
|
||||
posts.forEach(p => set.add(p.date))
|
||||
uv.forEach(p => set.add(p.date))
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
function mapByDate(list: TrendPoint[] | undefined, cats: string[]): number[] {
|
||||
const map = new Map((list || []).map(i => [i.date, trendPointValue(i)]))
|
||||
return cats.map(d => map.get(d) ?? 0)
|
||||
}
|
||||
|
||||
const lineSeries = computed(() => {
|
||||
const cats = trendCategories.value
|
||||
return [
|
||||
{ name: '发文', data: mapByDate(stats.value.postsTrend, cats) },
|
||||
{ name: 'UV', data: mapByDate(stats.value.uvTrend, cats) },
|
||||
]
|
||||
})
|
||||
|
||||
const barCategories = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => String(p.title || '').slice(0, 6) || '—'),
|
||||
)
|
||||
const barValues = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => Number(p.count ?? p.readCount ?? 0)),
|
||||
)
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function logLabel(item: unknown): string {
|
||||
if (!item || typeof item !== 'object') return String(item)
|
||||
const row = item as Record<string, unknown>
|
||||
return String(row.action || row.description || row.module || row.id || '操作记录')
|
||||
}
|
||||
|
||||
function logTime(item: unknown): string {
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
const row = item as Record<string, unknown>
|
||||
return String(row.createdAt || row.time || '')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
apply()
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [s, logRes, postsRes] = await Promise.all([
|
||||
getDashboardStats().catch(() => ({}) as DashboardStats),
|
||||
getOperationLogs(1, 10).catch(() => ({ list: [] })),
|
||||
adminList('posts', { page: 1, pageSize: 5 }).catch(() => []),
|
||||
])
|
||||
stats.value = s || {}
|
||||
const list = Array.isArray(logRes) ? logRes : (logRes as { list?: unknown[] }).list || []
|
||||
logs.value = list
|
||||
recentPosts.value = normalizeAdminList(postsRes) as Post[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function go(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function goPost(item: Post) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/posts/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="工作台" />
|
||||
<view class="page-pad">
|
||||
<view class="hero-eyebrow">Dashboard</view>
|
||||
<view class="section-title" style="margin-bottom: 32rpx;">概览</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<view
|
||||
v-for="item in kpiList"
|
||||
:key="item.key"
|
||||
class="admin-card stat"
|
||||
@click="go(item.url)"
|
||||
>
|
||||
<text class="font-mono-label text-art-muted">{{ item.label }}</text>
|
||||
<text class="num">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">发文 / UV 趋势</text>
|
||||
<AdminLineChart
|
||||
v-if="trendCategories.length"
|
||||
:categories="trendCategories"
|
||||
:series="lineSeries"
|
||||
/>
|
||||
<wd-empty v-else description="暂无趋势数据" />
|
||||
</view>
|
||||
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">热门文章阅读</text>
|
||||
<AdminBarChart
|
||||
v-if="barCategories.length"
|
||||
:categories="barCategories"
|
||||
:values="barValues"
|
||||
/>
|
||||
<wd-empty v-else description="暂无热门数据" />
|
||||
</view>
|
||||
|
||||
<view class="section-head">
|
||||
<text class="section-title" style="font-size: 34rpx;">最近文章</text>
|
||||
<text class="link text-art-accent" @click="go('/subPackages/admin/pages/posts/list')">全部</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in recentPosts"
|
||||
:key="String(item.id)"
|
||||
class="admin-card log"
|
||||
@click="goPost(item)"
|
||||
>
|
||||
<text class="log-title">{{ item.title }}</text>
|
||||
<text class="font-mono-label text-art-muted">{{ item.categoryName || '' }} · {{ item.date || '' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="section-head" style="margin-top: 28rpx;">
|
||||
<text class="section-title" style="font-size: 34rpx;">最近操作</text>
|
||||
<text class="link text-art-accent" @click="go('/subPackages/admin/pages/logs/operation')">全部</text>
|
||||
</view>
|
||||
<view v-for="(item, idx) in logs" :key="idx" class="admin-card log">
|
||||
<text class="log-title">{{ logLabel(item) }}</text>
|
||||
<text class="font-mono-label text-art-muted">{{ logTime(item) }}</text>
|
||||
</view>
|
||||
<wd-empty v-if="!loading && !logs.length" description="暂无日志" />
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20rpx; margin-bottom: 28rpx; }
|
||||
.stat { padding: 28rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.num { font-size: 44rpx; color: rgb(var(--art-accent)); font-family: monospace; }
|
||||
.chart-card { padding: 24rpx; margin-bottom: 24rpx; }
|
||||
.chart-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.section-head {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-bottom: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.link { font-size: 24rpx; }
|
||||
.log {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.log-title { font-size: 28rpx; color: rgb(var(--art-text)); }
|
||||
</style>
|
||||
132
blog-wot-uniapp/src/subPackages/admin/pages/explore/index.vue
Normal file
132
blog-wot-uniapp/src/subPackages/admin/pages/explore/index.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台功能入口:顶栏快捷 + Tab 分组 + 4 列金刚区。
|
||||
* 不用 wd-collapse/cell 白底列表,暗色走 AppPageShell admin。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { ADMIN_MENUS, type MenuItem } from '@/config/menus'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
/** 0 = 全部,其余对应 groups 下标 +1 */
|
||||
const tabIndex = ref(0)
|
||||
|
||||
const quickLinks = computed(() => ADMIN_MENUS.filter(m => m.path))
|
||||
const groups = computed(() => ADMIN_MENUS.filter(m => m.children?.length))
|
||||
|
||||
const tabLabels = computed(() => ['全部', ...groups.value.map(g => g.title)])
|
||||
|
||||
/** 当前 Tab 下的入口列表(扁平) */
|
||||
const gridItems = computed(() => {
|
||||
if (tabIndex.value === 0) {
|
||||
return groups.value.flatMap(g => g.children || [])
|
||||
}
|
||||
const g = groups.value[tabIndex.value - 1]
|
||||
return g?.children || []
|
||||
})
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
apply()
|
||||
guardAdmin()
|
||||
})
|
||||
|
||||
const open = (item: MenuItem) => {
|
||||
if (!item.path) return
|
||||
uni.navigateTo({ url: item.path })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="功能入口" />
|
||||
<view class="page-pad">
|
||||
<view class="hero-eyebrow">Admin</view>
|
||||
<view class="section-title" style="margin-bottom: 24rpx;">后台菜单</view>
|
||||
|
||||
<view class="quick-row">
|
||||
<view
|
||||
v-for="item in quickLinks"
|
||||
:key="item.key"
|
||||
class="admin-card quick"
|
||||
@click="open(item)"
|
||||
>
|
||||
<wd-icon :name="item.icon || 'apps'" size="22px" color="rgb(212,179,131)" />
|
||||
<text class="quick-title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-tabs v-model="tabIndex" slidable="always">
|
||||
<wd-tab v-for="(label, i) in tabLabels" :key="i" :title="label" :name="i" />
|
||||
</wd-tabs>
|
||||
|
||||
<view class="grid">
|
||||
<view
|
||||
v-for="item in gridItems"
|
||||
:key="item.key"
|
||||
class="admin-card cell"
|
||||
@click="open(item)"
|
||||
>
|
||||
<wd-icon :name="item.icon || 'apps'" size="26px" color="rgb(212,179,131)" />
|
||||
<text class="cell-title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-empty v-if="!gridItems.length" description="暂无入口" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.quick-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.quick {
|
||||
padding: 28rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.quick-title { font-size: 28rpx; color: rgb(var(--art-text)); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.cell {
|
||||
padding: 24rpx 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
min-height: 160rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.cell-title {
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
150
blog-wot-uniapp/src/subPackages/admin/pages/inquiries/list.vue
Normal file
150
blog-wot-uniapp/src/subPackages/admin/pages/inquiries/list.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 合作咨询:只读列表 + page-container 详情 + 状态流转(未读→已读→已联系)。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminList, normalizeAdminList, updateInquiryStatus, type Inquiry } from '@/api'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
'0': '未读',
|
||||
'1': '已读',
|
||||
'2': '已联系',
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const list = ref<Inquiry[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const detailShow = ref(false)
|
||||
const current = ref<Inquiry | null>(null)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '未读', value: 0 },
|
||||
{ label: '已读', value: 1 },
|
||||
{ label: '已联系', value: 2 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = normalizeAdminList(await adminList('inquiries', {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})) as Inquiry[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function openDetail(item: Inquiry) {
|
||||
current.value = { ...item }
|
||||
detailShow.value = true
|
||||
// 未读自动标已读
|
||||
if (Number(item.status) === 0) {
|
||||
try {
|
||||
await updateInquiryStatus(item.id, 1)
|
||||
item.status = 1
|
||||
current.value.status = 1
|
||||
}
|
||||
catch { /* 忽略自动标记失败 */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(status: number) {
|
||||
if (!current.value?.id) return
|
||||
try {
|
||||
await updateInquiryStatus(current.value.id, status)
|
||||
current.value.status = status
|
||||
const hit = list.value.find(i => i.id === current.value!.id)
|
||||
if (hit) hit.status = status
|
||||
toast.success('状态已更新')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function detailRows() {
|
||||
const c = current.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '姓名', value: String(c.name || '') },
|
||||
{ label: '公司', value: String(c.company || '') },
|
||||
{ label: '联系方式', value: `${c.contactMethod || ''} ${c.contactValue || ''}` },
|
||||
{ label: '预算', value: String(c.budget || '') },
|
||||
{ label: '需求', value: String(c.description || '') },
|
||||
{ label: '状态', value: STATUS_MAP[String(c.status ?? 0)] || '' },
|
||||
{ label: '时间', value: String(c.createdAt || '') },
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="合作咨询" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load"
|
||||
@reset="load"
|
||||
/>
|
||||
<view v-for="item in list" :key="item.id" class="admin-card row" @click="openDetail(item)">
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.name || '匿名' }}</text>
|
||||
<AdminStatusBadge :value="item.status" :map="STATUS_MAP" />
|
||||
</view>
|
||||
<text class="sub text-art-muted">{{ item.company || '—' }} · {{ item.createdAt || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无咨询" />
|
||||
</view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="detailShow" title="咨询详情" :rows="detailRows()">
|
||||
<template #footer>
|
||||
<wd-button size="small" @click="setStatus(1)">标为已读</wd-button>
|
||||
<wd-button size="small" type="primary" @click="setStatus(2)">标为已联系</wd-button>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; }
|
||||
.main { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title { flex: 1; font-size: 30rpx; color: rgb(var(--art-text)); }
|
||||
.sub { font-size: 24rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 配置:分厂商 Tab,保存 / 测试连接 / 删除。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminDelete, adminList, adminUpdate, normalizeAdminList, testOssConfig } from '@/api'
|
||||
|
||||
type StorageType = 'local' | 'aliyun' | 'qcloud' | 'qiniu'
|
||||
|
||||
const TABS: { id: StorageType, label: string }[] = [
|
||||
{ id: 'local', label: '本地' },
|
||||
{ id: 'aliyun', label: '阿里云' },
|
||||
{ id: 'qcloud', label: '腾讯云' },
|
||||
{ id: 'qiniu', label: '七牛' },
|
||||
]
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tab = ref(0)
|
||||
const configs = ref<Record<string, unknown>[]>([])
|
||||
|
||||
const form = reactive({
|
||||
id: '' as string | number,
|
||||
name: '',
|
||||
storageType: 'local' as string,
|
||||
isActive: 1 as number,
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
secretId: '',
|
||||
secretKey: '',
|
||||
accessKey: '',
|
||||
endpoint: '',
|
||||
region: '',
|
||||
bucket: '',
|
||||
domain: '',
|
||||
})
|
||||
|
||||
const currentType = computed(() => TABS[tab.value]?.id || 'local')
|
||||
|
||||
function fillForm(row?: Record<string, unknown>) {
|
||||
const type = currentType.value
|
||||
form.id = ''
|
||||
form.accessKeyId = ''
|
||||
form.accessKeySecret = ''
|
||||
form.secretId = ''
|
||||
form.secretKey = ''
|
||||
form.accessKey = ''
|
||||
form.endpoint = ''
|
||||
form.region = ''
|
||||
form.bucket = ''
|
||||
form.domain = ''
|
||||
form.storageType = type
|
||||
form.isActive = 1
|
||||
form.name = `${TABS[tab.value]?.label || type} 存储`
|
||||
if (!row) return
|
||||
form.id = (row.id as string | number) || ''
|
||||
form.name = String(row.name || form.name)
|
||||
form.storageType = String(row.storageType || type)
|
||||
form.isActive = row.isActive === 0 || row.isActive === false ? 0 : 1
|
||||
form.accessKeyId = String(row.accessKeyId || '')
|
||||
form.accessKeySecret = row.accessKeySecret === '***' ? '' : String(row.accessKeySecret || '')
|
||||
form.secretId = String(row.secretId || '')
|
||||
form.secretKey = row.secretKey === '***' ? '' : String(row.secretKey || '')
|
||||
form.accessKey = String(row.accessKey || '')
|
||||
form.endpoint = String(row.endpoint || '')
|
||||
form.region = String(row.region || '')
|
||||
form.bucket = String(row.bucket || '')
|
||||
form.domain = String(row.domain || '')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
configs.value = normalizeAdminList(await adminList('oss-configs'))
|
||||
const hit = configs.value.find(c => String(c.storageType) === currentType.value)
|
||||
fillForm(hit)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
const hit = configs.value.find(c => String(c.storageType) === currentType.value)
|
||||
fillForm(hit)
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function onSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const { id, ...rest } = form
|
||||
const payload = { ...rest }
|
||||
if (id) await adminUpdate('oss-configs', id, payload)
|
||||
else await adminCreate('oss-configs', payload)
|
||||
toast.success('已保存')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
if (!form.id) {
|
||||
toast.show('请先保存配置')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await testOssConfig(form.id as string | number)
|
||||
toast.success('连接成功')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!form.id) return
|
||||
dialog.confirm({ title: '确认删除', msg: '确定删除该 OSS 配置吗?' }).then(async () => {
|
||||
try {
|
||||
await adminDelete('oss-configs', form.id as string | number)
|
||||
toast.success('已删除')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="OSS 配置" show-back />
|
||||
<view class="page-pad">
|
||||
<wd-tabs v-model="tab" @change="onTabChange">
|
||||
<wd-tab v-for="t in TABS" :key="t.id" :title="t.label" />
|
||||
</wd-tabs>
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.name" label="名称" clearable />
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean) => form.isActive = v ? 1 : 0" />
|
||||
</view>
|
||||
<template v-if="currentType === 'aliyun'">
|
||||
<wd-input v-model="form.accessKeyId" label="AccessKeyId" clearable />
|
||||
<wd-input v-model="form.accessKeySecret" label="AccessKeySecret" clearable />
|
||||
<wd-input v-model="form.endpoint" label="Endpoint" clearable />
|
||||
<wd-input v-model="form.bucket" label="Bucket" clearable />
|
||||
<wd-input v-model="form.domain" label="Domain" clearable />
|
||||
</template>
|
||||
<template v-else-if="currentType === 'qcloud'">
|
||||
<wd-input v-model="form.secretId" label="SecretId" clearable />
|
||||
<wd-input v-model="form.secretKey" label="SecretKey" clearable />
|
||||
<wd-input v-model="form.region" label="Region" clearable />
|
||||
<wd-input v-model="form.bucket" label="Bucket" clearable />
|
||||
<wd-input v-model="form.domain" label="Domain" clearable />
|
||||
</template>
|
||||
<template v-else-if="currentType === 'qiniu'">
|
||||
<wd-input v-model="form.accessKey" label="AccessKey" clearable />
|
||||
<wd-input v-model="form.secretKey" label="SecretKey" clearable />
|
||||
<wd-input v-model="form.bucket" label="Bucket" clearable />
|
||||
<wd-input v-model="form.region" label="Region" clearable />
|
||||
<wd-input v-model="form.domain" label="Domain" clearable />
|
||||
</template>
|
||||
<template v-else>
|
||||
<text class="hint text-art-muted">本地存储无需额外密钥,仅需启用即可。</text>
|
||||
</template>
|
||||
<view class="actions">
|
||||
<wd-button type="primary" :loading="submitting" @click="onSave">保存</wd-button>
|
||||
<wd-button plain @click="onTest">测试连接</wd-button>
|
||||
<wd-button v-if="form.id" type="warning" plain @click="onDelete">删除</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; margin-top: 20rpx; }
|
||||
.switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
}
|
||||
.hint { font-size: 24rpx; display: block; margin: 16rpx 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 24rpx; }
|
||||
</style>
|
||||
354
blog-wot-uniapp/src/subPackages/admin/pages/posts/form.vue
Normal file
354
blog-wot-uniapp/src/subPackages/admin/pages/posts/form.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文章编辑表单:结构化字段 + 简易 Markdown 工具条(插图走附件上传)。
|
||||
* 分类/专栏用 wd-picker,空列时塞占位项,避免 wot 内部 map 读 value of undefined。
|
||||
* 版本历史/访问日志共用一个 page-container(小程序同页只能有一个)。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminCreate,
|
||||
adminGetPost,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
getPostAccessLogs,
|
||||
getPostHistory,
|
||||
normalizeAdminList,
|
||||
restorePostVersion,
|
||||
uploadAttachment,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
/** 专栏列表;勿命名 columns,避免与 wd-picker 的 columns 属性混淆 */
|
||||
const columnList = ref<{ id: number, name?: string, title?: string }[]>([])
|
||||
const tags = ref<{ id: number, name: string }[]>([])
|
||||
const selectedTagIds = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
excerpt: '',
|
||||
content: '',
|
||||
cover: '',
|
||||
categoryId: '' as string | number,
|
||||
columnId: '' as string | number,
|
||||
date: '',
|
||||
isPublished: 1,
|
||||
})
|
||||
|
||||
/** 单一抽屉:history | access,避免双 page-container */
|
||||
const drawerShow = ref(false)
|
||||
const drawerMode = ref<'history' | 'access'>('history')
|
||||
const historyRows = ref<{ label: string, value: string }[]>([])
|
||||
const historyVersions = ref<{ version?: number | string, createdAt?: string }[]>([])
|
||||
const accessRows = ref<{ label: string, value: string }[]>([])
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建文章' : '编辑文章'))
|
||||
const drawerTitle = computed(() => (drawerMode.value === 'history' ? '版本历史' : '访问日志'))
|
||||
const drawerRows = computed(() =>
|
||||
drawerMode.value === 'history' ? historyRows.value : accessRows.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* wd-picker 列数据:空数组会触发内部 selectWithValue 崩溃,故至少给一项占位。
|
||||
*/
|
||||
const categoryColumns = computed(() => {
|
||||
const list = categories.value.map(c => ({ label: c.name, value: c.id }))
|
||||
return [list.length ? list : [{ label: '暂无分类', value: '' }]]
|
||||
})
|
||||
const columnColumns = computed(() => {
|
||||
const list = columnList.value.map(c => ({
|
||||
label: c.name || c.title || String(c.id),
|
||||
value: c.id,
|
||||
}))
|
||||
return [list.length ? list : [{ label: '暂无专栏', value: '' }]]
|
||||
})
|
||||
|
||||
function insertMd(prefix: string, suffix = '') {
|
||||
form.content = `${form.content}${prefix}${suffix}`
|
||||
}
|
||||
|
||||
async function insertImage() {
|
||||
try {
|
||||
const res = await uni.chooseImage({ count: 1, sizeType: ['compressed'] })
|
||||
const path = res.tempFilePaths?.[0]
|
||||
if (!path) return
|
||||
const uploaded = await uploadAttachment(path)
|
||||
const url = uploaded.fileUrl || uploaded.url || ''
|
||||
if (url) insertMd(`\n\n`)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '插图失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const [cats, cols, tgs] = await Promise.all([
|
||||
adminList('categories').catch(() => []),
|
||||
adminList('columns').catch(() => []),
|
||||
adminList('tags').catch(() => []),
|
||||
])
|
||||
categories.value = normalizeAdminList(cats) as { id: number, name: string }[]
|
||||
columnList.value = normalizeAdminList(cols) as { id: number, name?: string, title?: string }[]
|
||||
tags.value = normalizeAdminList(tgs) as { id: number, name: string }[]
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
await loadMeta()
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
// 走后台详情接口(含未发布),勿用公开 getPost / 已删除的 adminGet posts
|
||||
const data = await adminGetPost(id.value)
|
||||
form.title = String(data.title || '')
|
||||
form.excerpt = String(data.excerpt || data.summary || '')
|
||||
form.content = String(data.content || '')
|
||||
form.cover = String(data.cover || '')
|
||||
form.categoryId = (data.categoryId as number) || ''
|
||||
form.columnId = (data.columnId as number) || ''
|
||||
form.date = String(data.date || '')
|
||||
form.isPublished = data.isPublished === 0 || data.isPublished === false ? 0 : 1
|
||||
selectedTagIds.value = (data.tags || []).map(t => Number(t.id))
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function toggleTag(tid: number) {
|
||||
const i = selectedTagIds.value.indexOf(tid)
|
||||
if (i >= 0) selectedTagIds.value.splice(i, 1)
|
||||
else selectedTagIds.value.push(tid)
|
||||
}
|
||||
|
||||
function onCatConfirm(payload: { value?: unknown[] }) {
|
||||
const v = payload?.value?.[0]
|
||||
const raw = (v && typeof v === 'object' && 'value' in v)
|
||||
? (v as { value: unknown }).value
|
||||
: v
|
||||
form.categoryId = (raw as number) || ''
|
||||
}
|
||||
|
||||
function onColConfirm(payload: { value?: unknown[] }) {
|
||||
const v = payload?.value?.[0]
|
||||
const raw = (v && typeof v === 'object' && 'value' in v)
|
||||
? (v as { value: unknown }).value
|
||||
: v
|
||||
form.columnId = (raw as number) || ''
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
excerpt: form.excerpt,
|
||||
content: form.content,
|
||||
cover: form.cover,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : undefined,
|
||||
columnId: form.columnId ? Number(form.columnId) : undefined,
|
||||
date: form.date || undefined,
|
||||
isPublished: form.isPublished,
|
||||
tagIds: selectedTagIds.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('posts', id.value, payload)
|
||||
else await adminCreate('posts', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openHistory() {
|
||||
if (!id.value) return
|
||||
try {
|
||||
const list = await getPostHistory(id.value)
|
||||
historyVersions.value = (Array.isArray(list) ? list : []) as typeof historyVersions.value
|
||||
historyRows.value = historyVersions.value.map((v, i) => ({
|
||||
label: `版本 ${v.version ?? i + 1}`,
|
||||
value: String(v.createdAt || ''),
|
||||
}))
|
||||
drawerMode.value = 'history'
|
||||
drawerShow.value = true
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载历史失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(version: string | number) {
|
||||
try {
|
||||
await restorePostVersion(id.value, version)
|
||||
toast.success('已恢复')
|
||||
drawerShow.value = false
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '恢复失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openAccess() {
|
||||
if (!id.value) return
|
||||
try {
|
||||
const data = await getPostAccessLogs(id.value, { page: 1, pageSize: 20 })
|
||||
const list = Array.isArray(data) ? data : data.list || []
|
||||
accessRows.value = list.map((row: unknown, i: number) => {
|
||||
const r = row as Record<string, unknown>
|
||||
return {
|
||||
label: `#${i + 1} ${r.path || r.ip || ''}`,
|
||||
value: String(r.createdAt || r.region || ''),
|
||||
}
|
||||
})
|
||||
drawerMode.value = 'access'
|
||||
drawerShow.value = true
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载访问日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel() {
|
||||
return categories.value.find(c => String(c.id) === String(form.categoryId))?.name || '选择分类'
|
||||
}
|
||||
function colLabel() {
|
||||
const c = columnList.value.find(c => String(c.id) === String(form.columnId))
|
||||
return c?.name || c?.title || '选择专栏(可选)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<AdminFormField label="标题" required>
|
||||
<wd-input v-model="form.title" placeholder="请输入标题" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="摘要">
|
||||
<wd-textarea v-model="form.excerpt" placeholder="文章摘要" />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<wd-picker :columns="categoryColumns" @confirm="onCatConfirm">
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ catLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<wd-picker :columns="columnColumns" @confirm="onColConfirm">
|
||||
<view class="picker-row"><text>专栏</text><text class="val">{{ colLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<AdminFormField label="日期">
|
||||
<wd-input v-model="form.date" placeholder="YYYY-MM-DD" clearable />
|
||||
</AdminFormField>
|
||||
<view class="switch-row">
|
||||
<text>发布</text>
|
||||
<wd-switch :model-value="!!form.isPublished" @change="(v: boolean) => form.isPublished = v ? 1 : 0" />
|
||||
</view>
|
||||
<view class="tags">
|
||||
<text class="label text-art-muted">标签</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
class="tag"
|
||||
:class="{ on: selectedTagIds.includes(t.id) }"
|
||||
@click="toggleTag(t.id)"
|
||||
>
|
||||
{{ t.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="md-tools">
|
||||
<wd-button size="small" plain @click="insertMd('**', '**')">加粗</wd-button>
|
||||
<wd-button size="small" plain @click="insertMd('[链接](', ')')">链接</wd-button>
|
||||
<wd-button size="small" plain @click="insertImage">插图</wd-button>
|
||||
</view>
|
||||
<AdminFormField label="正文 Markdown" required>
|
||||
<wd-textarea v-model="form.content" placeholder="支持 Markdown" :rows="12" />
|
||||
</AdminFormField>
|
||||
<view v-if="mode === 'edit'" class="extra">
|
||||
<wd-button size="small" plain @click="openHistory">版本历史</wd-button>
|
||||
<wd-button size="small" plain @click="openAccess">访问日志</wd-button>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="drawerShow" :title="drawerTitle" :rows="drawerRows">
|
||||
<template v-if="drawerMode === 'history'">
|
||||
<view v-for="(v, i) in historyVersions" :key="i" class="hist-row">
|
||||
<wd-button size="small" @click="restore(v.version ?? i + 1)">恢复此版本</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); font-size: 26rpx; }
|
||||
.tags { margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
.md-tools { display: flex; gap: 12rpx; margin: 16rpx 0; flex-wrap: wrap; }
|
||||
.extra { display: flex; gap: 12rpx; margin-top: 16rpx; }
|
||||
.hist-row { margin: 8rpx 0; }
|
||||
</style>
|
||||
187
blog-wot-uniapp/src/subPackages/admin/pages/posts/list.vue
Normal file
187
blog-wot-uniapp/src/subPackages/admin/pages/posts/list.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文章管理列表:服务端分页 + 筛选 + 行内发布切换 + 删除。
|
||||
* 关联/历史/访问日志放在编辑页,降低列表复杂度。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminDelete,
|
||||
adminList,
|
||||
normalizePagination,
|
||||
patchPostStatus,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Post[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const finished = ref(false)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'isPublished',
|
||||
label: '发布',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '已发布', value: 1 },
|
||||
{ label: '草稿', value: 0 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminList('posts', {
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})
|
||||
const p = normalizePagination<Post>(data, page.value, 20)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
total.value = p.total
|
||||
finished.value = list.value.length >= p.total
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/posts/form?mode=create' })
|
||||
}
|
||||
|
||||
function goEdit(item: Post) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/posts/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
|
||||
async function togglePublish(item: Post) {
|
||||
const next = item.isPublished === 1 || item.isPublished === true ? 0 : 1
|
||||
try {
|
||||
await patchPostStatus(item.id, next)
|
||||
item.isPublished = next
|
||||
toast.success(next ? '已发布' : '已设为草稿')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(item: Post) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除「${item.title}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('posts', item.id)
|
||||
toast.success('已删除')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="文章管理" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
<view v-for="item in list" :key="String(item.id)" class="admin-card row" @click="goEdit(item)">
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<AdminStatusBadge
|
||||
:value="item.isPublished"
|
||||
:map="{ '1': '已发布', '0': '草稿', true: '已发布', false: '草稿' }"
|
||||
clickable
|
||||
@click="togglePublish(item)"
|
||||
/>
|
||||
</view>
|
||||
<text class="sub text-art-muted">
|
||||
{{ item.categoryName || '未分类' }} · {{ item.date || '' }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无文章" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate">
|
||||
<wd-icon name="plus" size="24px" color="#fff" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.row {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 30rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
199
blog-wot-uniapp/src/subPackages/admin/pages/profile/index.vue
Normal file
199
blog-wot-uniapp/src/subPackages/admin/pages/profile/index.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台个人中心:资料编辑(头像/联系方式)+ 修改密码。
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { getCurrentUser, updateMe, updatePassword } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const toast = useToast()
|
||||
const { isAuthenticated, getUser, setUser } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const pwdSubmitting = ref(false)
|
||||
const user = ref(getUser())
|
||||
const profile = reactive({
|
||||
email: '',
|
||||
bio: '',
|
||||
phone: '',
|
||||
wechat: '',
|
||||
avatar: '',
|
||||
wechatQrcode: '',
|
||||
})
|
||||
const form = reactive({ oldPassword: '', newPassword: '', confirm: '' })
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function load() {
|
||||
apply()
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const me = await getCurrentUser()
|
||||
user.value = { ...me } as typeof user.value
|
||||
profile.email = me.email || ''
|
||||
profile.bio = me.bio || ''
|
||||
profile.phone = me.phone || ''
|
||||
profile.wechat = me.wechat || ''
|
||||
profile.avatar = me.avatar || ''
|
||||
profile.wechatQrcode = me.wechatQrcode || ''
|
||||
}
|
||||
catch {
|
||||
user.value = getUser()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function saveProfile() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const updated = await updateMe({ ...profile })
|
||||
if (updated) setUser(updated as never)
|
||||
toast.success('资料已保存')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!form.oldPassword || !form.newPassword) {
|
||||
toast.show('请填写原密码与新密码')
|
||||
return
|
||||
}
|
||||
if (form.newPassword !== form.confirm) {
|
||||
toast.show('两次新密码不一致')
|
||||
return
|
||||
}
|
||||
pwdSubmitting.value = true
|
||||
try {
|
||||
await updatePassword(form.oldPassword, form.newPassword)
|
||||
toast.success('密码已更新')
|
||||
form.oldPassword = ''
|
||||
form.newPassword = ''
|
||||
form.confirm = ''
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
finally {
|
||||
pwdSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="个人中心" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="avatar-row">
|
||||
<image
|
||||
class="avatar"
|
||||
:src="resolveMediaUrl(profile.avatar || user.avatar) || '/static/logo.svg'"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="meta">
|
||||
<text class="name">{{ user.username }}</text>
|
||||
<text class="role text-art-accent font-mono-label">{{ user.role || 'staff' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="admin-card form-card">
|
||||
<view class="section-title" style="font-size: 34rpx; margin-bottom: 24rpx;">个人资料</view>
|
||||
<AdminMediaPicker v-model="profile.avatar" label="头像" />
|
||||
<AdminFormField label="邮箱">
|
||||
<wd-input v-model="profile.email" placeholder="邮箱" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="简介">
|
||||
<wd-textarea v-model="profile.bio" placeholder="简介" />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="手机">
|
||||
<wd-input v-model="profile.phone" placeholder="手机" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="微信">
|
||||
<wd-input v-model="profile.wechat" placeholder="微信" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="profile.wechatQrcode" label="微信二维码" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="saveProfile"
|
||||
>
|
||||
保存资料
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view class="admin-card form-card" style="margin-top: 24rpx;">
|
||||
<view class="section-title" style="font-size: 34rpx; margin-bottom: 24rpx;">修改密码</view>
|
||||
<AdminFormField label="原密码">
|
||||
<wd-input v-model="form.oldPassword" placeholder="原密码" show-password clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="新密码">
|
||||
<wd-input v-model="form.newPassword" placeholder="新密码" show-password clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="确认新密码">
|
||||
<wd-input v-model="form.confirm" placeholder="确认新密码" show-password clearable />
|
||||
</AdminFormField>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="pwdSubmitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
更新密码
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.meta { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.name { font-size: 36rpx; color: rgb(var(--art-text)); font-weight: 600; }
|
||||
.role { font-size: 22rpx; }
|
||||
</style>
|
||||
251
blog-wot-uniapp/src/subPackages/admin/pages/resource/form.vue
Normal file
251
blog-wot-uniapp/src/subPackages/admin/pages/resource/form.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用资源表单:按 resourceSchemas.fields 渲染,支持 media/switch/select。
|
||||
* JSON 模式仅作高级兜底;复杂资源应走专用页。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { adminCreate, adminGet, adminUpdate } from '@/api'
|
||||
import { getResourceSchema, type SchemaField } from '../../config/resourceSchemas'
|
||||
|
||||
const toast = useToast()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
|
||||
const resource = ref('')
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const jsonMode = ref(false)
|
||||
const form = reactive<Record<string, unknown>>({})
|
||||
const rawJson = ref('{}')
|
||||
|
||||
const schema = computed(() => getResourceSchema(resource.value))
|
||||
const fields = computed(() => schema.value.fields.filter(f => !f.editOnly || mode.value === 'edit'))
|
||||
const pageTitle = computed(() => {
|
||||
const label = schema.value.title || resource.value
|
||||
return mode.value === 'create' ? `新建${label}` : `编辑${label}`
|
||||
})
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function initEmpty() {
|
||||
Object.keys(form).forEach(k => delete form[k])
|
||||
fields.value.forEach((f) => {
|
||||
if (f.type === 'switch') form[f.key] = 1
|
||||
else if (f.type === 'number' || f.type === 'rating') form[f.key] = f.type === 'rating' ? 5 : 0
|
||||
else form[f.key] = ''
|
||||
})
|
||||
rawJson.value = '{}'
|
||||
}
|
||||
|
||||
function fillFromRecord(data: Record<string, unknown>) {
|
||||
Object.keys(form).forEach(k => delete form[k])
|
||||
fields.value.forEach((f) => {
|
||||
const v = data[f.key]
|
||||
if (f.type === 'switch') form[f.key] = v === true || v === 1 || v === '1' ? 1 : 0
|
||||
else if (f.type === 'number' || f.type === 'rating') form[f.key] = Number(v ?? 0)
|
||||
else if (f.key === 'config' && typeof v === 'object') form[f.key] = JSON.stringify(v, null, 2)
|
||||
else form[f.key] = v ?? ''
|
||||
})
|
||||
// 保留未声明字段以便 JSON 模式完整编辑
|
||||
rawJson.value = JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!resource.value || !guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet(resource.value, id.value) as Record<string, unknown>
|
||||
fillFromRecord(data)
|
||||
}
|
||||
else {
|
||||
initEmpty()
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
resource.value = String(q?.resource || '')
|
||||
mode.value = (q?.mode === 'edit' ? 'edit' : 'create') as 'create' | 'edit'
|
||||
id.value = String(q?.id || '')
|
||||
// 无字段的资源强制 JSON
|
||||
if (!getResourceSchema(resource.value).fields.length) jsonMode.value = true
|
||||
})
|
||||
|
||||
onShow(load)
|
||||
|
||||
function buildPayload(): Record<string, unknown> {
|
||||
if (jsonMode.value) {
|
||||
try {
|
||||
return JSON.parse(rawJson.value || '{}') as Record<string, unknown>
|
||||
}
|
||||
catch {
|
||||
throw new Error('JSON 格式不正确')
|
||||
}
|
||||
}
|
||||
const payload: Record<string, unknown> = {}
|
||||
fields.value.forEach((f: SchemaField) => {
|
||||
let v = form[f.key]
|
||||
if (f.type === 'number' || f.type === 'rating') v = Number(v ?? 0)
|
||||
if (f.type === 'switch') v = v ? 1 : 0
|
||||
if (f.key === 'config' && typeof v === 'string') {
|
||||
try { v = JSON.parse(v) }
|
||||
catch { /* 保持字符串,后端可自行处理 */ }
|
||||
}
|
||||
payload[f.key] = v
|
||||
})
|
||||
return payload
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate(resource.value, id.value, payload)
|
||||
else await adminCreate(resource.value, payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectLabel(f: SchemaField) {
|
||||
const opts = f.options || []
|
||||
const hit = opts.find(o => String(o.value) === String(form[f.key]))
|
||||
return hit?.label || '请选择'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<template v-if="!jsonMode && fields.length">
|
||||
<template v-for="f in fields" :key="f.key">
|
||||
<AdminFormField v-if="f.type === 'text' || f.type === 'number'" :label="f.label">
|
||||
<wd-input
|
||||
:model-value="String(form[f.key] ?? '')"
|
||||
:placeholder="f.placeholder || f.label"
|
||||
:disabled="f.readonly"
|
||||
clearable
|
||||
@update:model-value="(v: string | number) => form[f.key] = v"
|
||||
/>
|
||||
</AdminFormField>
|
||||
<AdminFormField v-else-if="f.type === 'textarea'" :label="f.label">
|
||||
<wd-textarea
|
||||
:model-value="String(form[f.key] ?? '')"
|
||||
:placeholder="f.placeholder || f.label"
|
||||
@update:model-value="(v: string) => form[f.key] = v"
|
||||
/>
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker
|
||||
v-else-if="f.type === 'media'"
|
||||
:model-value="String(form[f.key] ?? '')"
|
||||
:label="f.label"
|
||||
@update:model-value="(v: string) => form[f.key] = v"
|
||||
/>
|
||||
<view v-else-if="f.type === 'switch'" class="switch-row">
|
||||
<text>{{ f.label }}</text>
|
||||
<wd-switch
|
||||
:model-value="!!form[f.key]"
|
||||
@change="(v: boolean) => form[f.key] = v ? 1 : 0"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="f.type === 'rating'" class="rating-row">
|
||||
<text class="label">{{ f.label }}</text>
|
||||
<view class="stars">
|
||||
<text
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="star"
|
||||
:class="{ on: Number(form[f.key]) >= n }"
|
||||
@click="form[f.key] = n"
|
||||
>
|
||||
★
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-picker
|
||||
v-else-if="f.type === 'select' && f.options"
|
||||
:columns="[f.options.map(o => ({ label: o.label, value: o.value }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form[f.key] = (value?.[0] as { value?: unknown })?.value ?? value?.[0] }"
|
||||
>
|
||||
<view class="picker-row">
|
||||
<text class="label">{{ f.label }}</text>
|
||||
<text class="val">{{ selectLabel(f) }}</text>
|
||||
</view>
|
||||
</wd-picker>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AdminFormField label="JSON">
|
||||
<wd-textarea v-model="rawJson" placeholder="{}" />
|
||||
</AdminFormField>
|
||||
</template>
|
||||
|
||||
<view class="actions">
|
||||
<wd-button size="small" plain @click="jsonMode = !jsonMode">
|
||||
{{ jsonMode ? '表单模式' : 'JSON 模式' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 16rpx; }
|
||||
.switch-row, .picker-row, .rating-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 0;
|
||||
color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.label { font-size: 28rpx; }
|
||||
.val { font-size: 26rpx; color: rgba(255, 255, 255, 0.65); }
|
||||
.stars { display: flex; gap: 8rpx; }
|
||||
.star { font-size: 36rpx; color: rgba(255, 255, 255, 0.25); }
|
||||
.star.on { color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
404
blog-wot-uniapp/src/subPackages/admin/pages/resource/list.vue
Normal file
404
blog-wot-uniapp/src/subPackages/admin/pages/resource/list.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用资源列表:按 resourceSchemas 渲染筛选/行信息/真只读。
|
||||
* 复杂模块已拆专用页;本页兜底简单 CRUD 与只读日志类资源。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { adminCreate, adminDelete, adminList, normalizeAdminList, normalizePagination } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
import { getResourceSchema, pickField } from '../../config/resourceSchemas'
|
||||
|
||||
/** 已拆专用页的资源:进入通用 list 时自动跳转 */
|
||||
const DEDICATED: Record<string, string> = {
|
||||
posts: '/subPackages/admin/pages/posts/list',
|
||||
works: '/subPackages/admin/pages/works/list',
|
||||
videos: '/subPackages/admin/pages/videos/list',
|
||||
attachments: '/subPackages/admin/pages/attachments/index',
|
||||
inquiries: '/subPackages/admin/pages/inquiries/list',
|
||||
'oss-configs': '/subPackages/admin/pages/oss-configs/index',
|
||||
about: '/subPackages/admin/pages/about/index',
|
||||
settings: '/subPackages/admin/pages/settings/index',
|
||||
logs: '/subPackages/admin/pages/logs/operation',
|
||||
'access-logs': '/subPackages/admin/pages/logs/access',
|
||||
users: '/subPackages/admin/pages/users/list',
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
|
||||
const resource = ref('')
|
||||
const loading = ref(false)
|
||||
const list = ref<Record<string, unknown>[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const finished = ref(false)
|
||||
|
||||
const drawerShow = ref(false)
|
||||
const drawerRows = ref<{ label: string, value: string }[]>([])
|
||||
const inlineForm = reactive<Record<string, unknown>>({})
|
||||
const inlineOpen = ref(false)
|
||||
|
||||
const schema = computed(() => getResourceSchema(resource.value))
|
||||
const pageTitle = computed(() => schema.value.title)
|
||||
const showFab = computed(() => {
|
||||
if (!resource.value) return false
|
||||
if (schema.value.readonly || schema.value.noEdit) return false
|
||||
if (schema.value.inlineCreate) return false
|
||||
return true
|
||||
})
|
||||
const canDelete = computed(() => !schema.value.readonly)
|
||||
const canEdit = computed(() => !schema.value.readonly && !schema.value.noEdit)
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function itemTitle(item: Record<string, unknown>) {
|
||||
return pickField(item, schema.value.titleKeys) || String(item.id || '未命名')
|
||||
}
|
||||
|
||||
function itemSubtitle(item: Record<string, unknown>) {
|
||||
return pickField(item, schema.value.subtitleKeys)
|
||||
}
|
||||
|
||||
function coverUrl(item: Record<string, unknown>) {
|
||||
const key = schema.value.coverKey
|
||||
if (!key) return ''
|
||||
return resolveMediaUrl(String(item[key] || ''))
|
||||
}
|
||||
|
||||
function matchLocal(item: Record<string, unknown>) {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return true
|
||||
const keys = schema.value.localSearchKeys || schema.value.titleKeys || []
|
||||
return keys.some(k => String(item[k] ?? '').toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
function matchFilters(item: Record<string, unknown>) {
|
||||
for (const [k, v] of Object.entries(filters.value)) {
|
||||
if (v === '' || v == null) continue
|
||||
if (k === 'keyword') continue
|
||||
if (String(item[k] ?? '') !== String(v)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!resource.value || !guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = { ...filters.value }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (schema.value.serverPaging) {
|
||||
params.page = page.value
|
||||
params.pageSize = schema.value.pageSize || pageSize.value
|
||||
}
|
||||
const data = await adminList(resource.value, params)
|
||||
if (schema.value.serverPaging) {
|
||||
const p = normalizePagination(data, page.value, schema.value.pageSize || pageSize.value)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
total.value = p.total
|
||||
finished.value = list.value.length >= p.total
|
||||
}
|
||||
else {
|
||||
let rows = normalizeAdminList(data)
|
||||
rows = rows.filter(matchLocal).filter(matchFilters)
|
||||
list.value = rows
|
||||
total.value = rows.length
|
||||
finished.value = true
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
resource.value = String(q?.resource || '')
|
||||
const jump = DEDICATED[resource.value]
|
||||
if (jump) {
|
||||
uni.redirectTo({ url: jump })
|
||||
return
|
||||
}
|
||||
pageSize.value = schema.value.pageSize || 20
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (DEDICATED[resource.value]) return
|
||||
load(true)
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!schema.value.serverPaging || finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
const goCreate = () => {
|
||||
if (schema.value.inlineCreate) {
|
||||
Object.keys(inlineForm).forEach(k => delete inlineForm[k])
|
||||
schema.value.fields.forEach((f) => {
|
||||
inlineForm[f.key] = f.type === 'switch' ? 1 : f.type === 'number' ? 0 : ''
|
||||
})
|
||||
inlineOpen.value = true
|
||||
return
|
||||
}
|
||||
// 专用表单入口
|
||||
if (resource.value === 'video-albums') {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/video-albums/form?mode=create' })
|
||||
return
|
||||
}
|
||||
if (resource.value === 'snippets') {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/snippets/form?mode=create' })
|
||||
return
|
||||
}
|
||||
if (resource.value === 'columns') {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/columns/form?mode=create' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/resource/form?resource=${resource.value}&mode=create`,
|
||||
})
|
||||
}
|
||||
|
||||
const goEdit = (item: Record<string, unknown>) => {
|
||||
if (schema.value.readonly) {
|
||||
openDetail(item)
|
||||
return
|
||||
}
|
||||
if (!canEdit.value) {
|
||||
openDetail(item)
|
||||
return
|
||||
}
|
||||
// 视频专辑编辑走专用 form(含视频多选)
|
||||
if (resource.value === 'video-albums' && item.id != null) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/video-albums/form?mode=edit&id=${item.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (resource.value === 'snippets' && item.id != null) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/snippets/form?mode=edit&id=${item.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (resource.value === 'columns' && item.id != null) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/columns/form?mode=edit&id=${item.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
const id = item.id
|
||||
if (id == null) return
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/resource/form?resource=${resource.value}&mode=edit&id=${id}`,
|
||||
})
|
||||
}
|
||||
|
||||
function openDetail(item: Record<string, unknown>) {
|
||||
drawerRows.value = Object.entries(item)
|
||||
.filter(([k]) => !['password'].includes(k))
|
||||
.slice(0, 30)
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : String(value ?? ''),
|
||||
}))
|
||||
drawerShow.value = true
|
||||
}
|
||||
|
||||
const onDelete = (item: Record<string, unknown>) => {
|
||||
if (!canDelete.value) return
|
||||
// 系统 PPT 模板禁止删除
|
||||
if (resource.value === 'ppt-templates' && item.isSystem) {
|
||||
toast.show('系统模板不可删除')
|
||||
return
|
||||
}
|
||||
const id = item.id
|
||||
if (id == null) return
|
||||
dialog.confirm({
|
||||
title: '确认删除',
|
||||
msg: `确定删除「${itemTitle(item)}」吗?`,
|
||||
}).then(async () => {
|
||||
try {
|
||||
await adminDelete(resource.value, id as string | number)
|
||||
toast.success('已删除')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function submitInline() {
|
||||
try {
|
||||
await adminCreate(resource.value, { ...inlineForm })
|
||||
toast.success('已创建')
|
||||
inlineOpen.value = false
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '创建失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-if="schema.filters?.length || schema.localSearchKeys?.length || schema.serverPaging"
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="schema.filters || []"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
|
||||
<view v-if="schema.inlineCreate" class="inline-create admin-card">
|
||||
<wd-button size="small" type="primary" @click="goCreate">新增</wd-button>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="String(item.id)"
|
||||
class="admin-card row"
|
||||
@click="goEdit(item)"
|
||||
>
|
||||
<image v-if="coverUrl(item)" class="cover" :src="coverUrl(item)" mode="aspectFill" />
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ itemTitle(item) }}</text>
|
||||
<AdminStatusBadge
|
||||
v-if="schema.statusKey"
|
||||
:value="item[schema.statusKey] as string | number | boolean"
|
||||
:map="schema.statusMap"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="itemSubtitle(item)" class="sub text-art-muted">{{ itemSubtitle(item) }}</text>
|
||||
</view>
|
||||
<wd-button
|
||||
v-if="canDelete"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
@click.stop="onDelete(item)"
|
||||
>
|
||||
删除
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无数据" />
|
||||
<text v-if="schema.serverPaging && !finished && list.length" class="more text-art-muted">上拉加载更多</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showFab" class="fab" @click="goCreate">
|
||||
<wd-icon name="plus" size="24px" color="#fff" />
|
||||
</view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="drawerShow" :title="pageTitle" :rows="drawerRows" />
|
||||
|
||||
<!-- 页内新建(邮箱后缀等) -->
|
||||
<AdminDetailDrawer v-model:show="inlineOpen" title="新建">
|
||||
<view v-for="f in schema.fields" :key="f.key" class="inline-field">
|
||||
<wd-input
|
||||
v-if="f.type === 'text' || f.type === 'number'"
|
||||
:model-value="String(inlineForm[f.key] ?? '')"
|
||||
:label="f.label"
|
||||
:placeholder="f.placeholder || f.label"
|
||||
clearable
|
||||
@update:model-value="(v: string | number) => inlineForm[f.key] = v"
|
||||
/>
|
||||
<view v-else-if="f.type === 'switch'" class="switch-row">
|
||||
<text>{{ f.label }}</text>
|
||||
<wd-switch :model-value="!!inlineForm[f.key]" @change="(v: boolean) => inlineForm[f.key] = v ? 1 : 0" />
|
||||
</view>
|
||||
</view>
|
||||
<template #footer>
|
||||
<wd-button block type="primary" @click="submitInline">保存</wd-button>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.inline-create { padding: 16rpx 24rpx; margin-bottom: 16rpx; }
|
||||
.row {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.cover {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 12rpx;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.main { flex: 1; display: flex; flex-direction: column; gap: 8rpx; min-width: 0; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title {
|
||||
font-size: 30rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.sub { font-size: 24rpx; }
|
||||
.more { display: block; text-align: center; font-size: 22rpx; padding: 16rpx; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.35);
|
||||
z-index: 100;
|
||||
}
|
||||
.inline-field { margin-bottom: 12rpx; }
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
</style>
|
||||
162
blog-wot-uniapp/src/subPackages/admin/pages/settings/index.vue
Normal file
162
blog-wot-uniapp/src/subPackages/admin/pages/settings/index.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 全局配置:按 settingsSchema 分 Tab 编辑,分组保存。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { batchUpdateSettings, getAdminSettings } from '@/api'
|
||||
import { MENU_OPTIONS, SETTING_GROUPS, SETTINGS_SCHEMA } from '../../config/settingsSchema'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tab = ref(0)
|
||||
const values = ref<Record<string, string>>({})
|
||||
|
||||
const currentGroup = computed(() => SETTING_GROUPS[tab.value]?.id || 'site')
|
||||
const currentFields = computed(() => SETTINGS_SCHEMA.filter(f => f.group === currentGroup.value))
|
||||
|
||||
function parseMenus(raw: string): string[] {
|
||||
try {
|
||||
const arr = JSON.parse(raw || '[]')
|
||||
return Array.isArray(arr) ? arr.map(String) : []
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu(key: string) {
|
||||
const list = parseMenus(values.value.visible_menus || '[]')
|
||||
const i = list.indexOf(key)
|
||||
if (i >= 0) list.splice(i, 1)
|
||||
else list.push(key)
|
||||
values.value.visible_menus = JSON.stringify(list)
|
||||
}
|
||||
|
||||
function normalizeSettings(data: unknown): Record<string, string> {
|
||||
const map: Record<string, string> = {}
|
||||
SETTINGS_SCHEMA.forEach((f) => { map[f.key] = f.default })
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((row: unknown) => {
|
||||
const r = row as { key?: string, keyName?: string, value?: string }
|
||||
const k = r.key || r.keyName
|
||||
if (k) map[k] = String(r.value ?? '')
|
||||
})
|
||||
}
|
||||
else if (data && typeof data === 'object') {
|
||||
Object.entries(data as Record<string, unknown>).forEach(([k, v]) => {
|
||||
map[k] = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
values.value = normalizeSettings(await getAdminSettings())
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function saveGroup() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: Record<string, string> = {}
|
||||
currentFields.value.forEach((f) => {
|
||||
payload[f.key] = values.value[f.key] ?? f.default
|
||||
})
|
||||
await batchUpdateSettings(payload)
|
||||
toast.success('本组已保存')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="全局配置" show-back />
|
||||
<view class="page-pad">
|
||||
<wd-tabs v-model="tab">
|
||||
<wd-tab v-for="g in SETTING_GROUPS" :key="g.id" :title="g.label" />
|
||||
</wd-tabs>
|
||||
<view class="admin-card form-card">
|
||||
<template v-for="f in currentFields" :key="f.key">
|
||||
<AdminFormField
|
||||
v-if="f.type === 'text' || f.type === 'email' || f.type === 'number'"
|
||||
:label="f.label"
|
||||
>
|
||||
<wd-input v-model="values[f.key]" :placeholder="f.label" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField
|
||||
v-else-if="f.type === 'textarea' || f.type === 'homepage-json'"
|
||||
:label="f.label"
|
||||
>
|
||||
<wd-textarea
|
||||
v-model="values[f.key]"
|
||||
:placeholder="f.label"
|
||||
:rows="f.type === 'homepage-json' ? 10 : 3"
|
||||
/>
|
||||
</AdminFormField>
|
||||
<view v-else-if="f.type === 'menu-checkboxes'" class="menus">
|
||||
<text class="label text-art-muted">{{ f.label }}</text>
|
||||
<view class="tags">
|
||||
<view
|
||||
v-for="m in MENU_OPTIONS"
|
||||
:key="m.key"
|
||||
class="tag"
|
||||
:class="{ on: parseMenus(values.visible_menus || '').includes(m.key) }"
|
||||
@click="toggleMenu(m.key)"
|
||||
>
|
||||
{{ m.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="saveGroup"
|
||||
>
|
||||
保存本组
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { margin-top: 20rpx; }
|
||||
.menus { margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 10rpx 18rpx; border-radius: 8rpx; font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
162
blog-wot-uniapp/src/subPackages/admin/pages/snippets/form.vue
Normal file
162
blog-wot-uniapp/src/subPackages/admin/pages/snippets/form.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 代码片段表单:代码大文本 + 分类 + 关联文章多选。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminList, adminUpdate, normalizeAdminList, type Post, type Snippet } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const codeTypes = ref<{ id: number, name: string }[]>([])
|
||||
const posts = ref<Post[]>([])
|
||||
const selectedPosts = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
codeTypeId: '' as string | number,
|
||||
code: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建代码片段' : '编辑代码片段'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
codeTypes.value = normalizeAdminList(await adminList('code-types')) as { id: number, name: string }[]
|
||||
const postData = await adminList('posts', { page: 1, pageSize: 100 })
|
||||
posts.value = normalizeAdminList(postData) as Post[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('snippets', id.value) as Snippet
|
||||
form.title = String(data.title || '')
|
||||
form.codeTypeId = data.codeTypeId || ''
|
||||
form.code = String(data.code || '')
|
||||
form.description = String(data.description || '')
|
||||
selectedPosts.value = Array.isArray(data.postIds) ? data.postIds.map(Number) : []
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function togglePost(pid: string | number) {
|
||||
const n = Number(pid)
|
||||
const i = selectedPosts.value.indexOf(n)
|
||||
if (i >= 0) selectedPosts.value.splice(i, 1)
|
||||
else selectedPosts.value.push(n)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
codeTypeId: form.codeTypeId ? Number(form.codeTypeId) : undefined,
|
||||
code: form.code,
|
||||
description: form.description,
|
||||
postIds: selectedPosts.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('snippets', id.value, payload)
|
||||
else await adminCreate('snippets', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel() {
|
||||
return codeTypes.value.find(c => String(c.id) === String(form.codeTypeId))?.name || '选择分类'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.title" label="标题" clearable />
|
||||
<wd-picker
|
||||
:columns="[codeTypes.map(c => ({ label: c.name, value: c.id }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.codeTypeId = (value?.[0] as { value?: number })?.value ?? value?.[0] as number }"
|
||||
>
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ typeLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<wd-textarea v-model="form.code" label="代码" :rows="10" custom-class="code-area" />
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">关联文章</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="p in posts"
|
||||
:key="String(p.id)"
|
||||
class="tag"
|
||||
:class="{ on: selectedPosts.includes(Number(p.id)) }"
|
||||
@click="togglePost(p.id)"
|
||||
>
|
||||
{{ p.title }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.picker-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); }
|
||||
.section { margin-top: 16rpx; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 16rpx; border-radius: 8rpx; font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
144
blog-wot-uniapp/src/subPackages/admin/pages/users/form.vue
Normal file
144
blog-wot-uniapp/src/subPackages/admin/pages/users/form.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 用户表单:完整资料字段。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminUpdate, type User } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
role: 'editor',
|
||||
isActive: 1,
|
||||
bio: '',
|
||||
phone: '',
|
||||
wechat: '',
|
||||
wechatQrcode: '',
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建用户' : '编辑用户'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
if (mode.value !== 'edit' || !id.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminGet('users', id.value) as User
|
||||
Object.assign(form, {
|
||||
username: data.username || '',
|
||||
email: data.email || '',
|
||||
avatar: data.avatar || '',
|
||||
role: data.role || 'editor',
|
||||
isActive: data.isActive === 0 ? 0 : 1,
|
||||
bio: data.bio || '',
|
||||
phone: data.phone || '',
|
||||
wechat: data.wechat || '',
|
||||
wechatQrcode: data.wechatQrcode || '',
|
||||
})
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.username.trim()) {
|
||||
toast.show('请填写用户名')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('users', id.value, { ...form })
|
||||
else await adminCreate('users', { ...form })
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<AdminFormField label="用户名" required>
|
||||
<wd-input v-model="form.username" placeholder="用户名" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="邮箱">
|
||||
<wd-input v-model="form.email" placeholder="邮箱" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.avatar" label="头像" />
|
||||
<wd-picker
|
||||
:columns="[[{ label: 'admin', value: 'admin' }, { label: 'editor', value: 'editor' }, { label: 'viewer', value: 'viewer' }]]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.role = String((value?.[0] as { value?: string })?.value ?? value?.[0] ?? 'editor') }"
|
||||
>
|
||||
<view class="picker-row"><text>角色</text><text class="val">{{ form.role }}</text></view>
|
||||
</wd-picker>
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean) => form.isActive = v ? 1 : 0" />
|
||||
</view>
|
||||
<AdminFormField label="简介">
|
||||
<wd-textarea v-model="form.bio" placeholder="简介" />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="手机">
|
||||
<wd-input v-model="form.phone" placeholder="手机" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="微信">
|
||||
<wd-input v-model="form.wechat" placeholder="微信" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.wechatQrcode" label="微信二维码" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); }
|
||||
</style>
|
||||
147
blog-wot-uniapp/src/subPackages/admin/pages/users/list.vue
Normal file
147
blog-wot-uniapp/src/subPackages/admin/pages/users/list.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 用户列表:服务端分页 + 角色/状态筛选。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminDelete, adminList, normalizePagination, type User } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const list = ref<User[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'admin', value: 'admin' },
|
||||
{ label: 'editor', value: 'editor' },
|
||||
{ label: 'viewer', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '状态',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminList('users', {
|
||||
page: page.value,
|
||||
pageSize: 10,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})
|
||||
const p = normalizePagination<User>(data, page.value, 10)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
finished.value = list.value.length >= p.total
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/users/form?mode=create' })
|
||||
}
|
||||
function goEdit(u: User) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/users/form?mode=edit&id=${u.id}` })
|
||||
}
|
||||
function onDelete(u: User) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除用户「${u.username}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('users', u.id)
|
||||
toast.success('已删除')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="用户管理" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
<view v-for="item in list" :key="item.id" class="admin-card row" @click="goEdit(item)">
|
||||
<image class="avatar" :src="resolveMediaUrl(item.avatar) || '/static/logo.svg'" mode="aspectFill" />
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.username }}</text>
|
||||
<AdminStatusBadge :value="item.isActive" :map="{ '1': '启用', '0': '停用', true: '启用', false: '停用' }" />
|
||||
</view>
|
||||
<text class="sub text-art-muted">{{ item.email }} · {{ item.role }}</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无用户" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate"><wd-icon name="plus" size="24px" color="#fff" /></view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; display: flex; align-items: center; gap: 16rpx; }
|
||||
.avatar { width: 80rpx; height: 80rpx; border-radius: 50%; background: rgba(255,255,255,0.06); }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title { flex: 1; font-size: 30rpx; color: rgb(var(--art-text)); }
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed; right: 40rpx; bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx; height: 96rpx; border-radius: 50%;
|
||||
background: rgb(var(--art-accent)); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频专辑表单:基础字段 + 编辑态视频多选。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminCreate,
|
||||
adminGet,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
getAlbumVideoIds,
|
||||
normalizeAdminList,
|
||||
type VideoAlbum,
|
||||
type VideoItem,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
const videos = ref<VideoItem[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
categoryId: '' as string | number,
|
||||
cover: '',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
isActive: 1,
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建专辑' : '编辑专辑'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = normalizeAdminList(await adminList('video-categories')) as { id: number, name: string }[]
|
||||
videos.value = normalizeAdminList(await adminList('videos')) as VideoItem[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('video-albums', id.value) as VideoAlbum
|
||||
form.name = String(data.name || '')
|
||||
form.categoryId = data.categoryId || ''
|
||||
form.cover = String(data.cover || '')
|
||||
form.description = String(data.description || '')
|
||||
form.sortOrder = Number(data.sortOrder || 0)
|
||||
form.isActive = data.isActive === 0 || data.isActive === false ? 0 : 1
|
||||
try {
|
||||
const ids = await getAlbumVideoIds(id.value)
|
||||
selectedIds.value = Array.isArray(ids) ? ids.map(Number) : []
|
||||
}
|
||||
catch {
|
||||
selectedIds.value = Array.isArray(data.videoIds) ? data.videoIds.map(Number) : []
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function toggleVideo(vid: string | number) {
|
||||
const n = Number(vid)
|
||||
const i = selectedIds.value.indexOf(n)
|
||||
if (i >= 0) selectedIds.value.splice(i, 1)
|
||||
else selectedIds.value.push(n)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.name.trim()) {
|
||||
toast.show('请填写名称')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : undefined,
|
||||
cover: form.cover,
|
||||
description: form.description,
|
||||
sortOrder: Number(form.sortOrder || 0),
|
||||
isActive: form.isActive,
|
||||
videoIds: selectedIds.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('video-albums', id.value, payload)
|
||||
else await adminCreate('video-albums', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel() {
|
||||
return categories.value.find(c => String(c.id) === String(form.categoryId))?.name || '选择分类'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.name" label="名称" clearable />
|
||||
<wd-picker
|
||||
:columns="[categories.map(c => ({ label: c.name, value: c.id }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.categoryId = (value?.[0] as { value?: number })?.value ?? value?.[0] as number }"
|
||||
>
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ catLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<wd-input v-model="form.sortOrder" label="排序" type="number" />
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean) => form.isActive = v ? 1 : 0" />
|
||||
</view>
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<view v-if="mode === 'edit'" class="section">
|
||||
<text class="label text-art-muted">关联视频 ({{ selectedIds.length }})</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="v in videos"
|
||||
:key="String(v.id)"
|
||||
class="tag"
|
||||
:class="{ on: selectedIds.includes(Number(v.id)) }"
|
||||
@click="toggleVideo(v.id)"
|
||||
>
|
||||
{{ v.title }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); }
|
||||
.section { margin-top: 16rpx; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 16rpx; border-radius: 8rpx; font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
174
blog-wot-uniapp/src/subPackages/admin/pages/videos/form.vue
Normal file
174
blog-wot-uniapp/src/subPackages/admin/pages/videos/form.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频表单:分类/专辑多选/封面/播放地址/发布状态。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminList, adminUpdate, normalizeAdminList, type VideoItem } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
const albums = ref<{ id: number, name: string }[]>([])
|
||||
const selectedAlbums = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
categoryId: '' as string | number,
|
||||
videoUrl: '',
|
||||
cover: '',
|
||||
description: '',
|
||||
isPublished: 1,
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建视频' : '编辑视频'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = normalizeAdminList(await adminList('video-categories')) as { id: number, name: string }[]
|
||||
albums.value = normalizeAdminList(await adminList('video-albums')) as { id: number, name: string }[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('videos', id.value) as VideoItem
|
||||
form.title = String(data.title || '')
|
||||
form.categoryId = data.categoryId || ''
|
||||
form.videoUrl = String(data.videoUrl || data.url || '')
|
||||
form.cover = String(data.cover || data.poster || '')
|
||||
form.description = String(data.description || '')
|
||||
form.isPublished = data.isPublished === 0 || data.isPublished === false ? 0 : 1
|
||||
selectedAlbums.value = Array.isArray(data.albumIds) ? data.albumIds.map(Number) : []
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function toggleAlbum(aid: number) {
|
||||
const i = selectedAlbums.value.indexOf(aid)
|
||||
if (i >= 0) selectedAlbums.value.splice(i, 1)
|
||||
else selectedAlbums.value.push(aid)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : undefined,
|
||||
videoUrl: form.videoUrl,
|
||||
cover: form.cover,
|
||||
poster: form.cover,
|
||||
description: form.description,
|
||||
isPublished: form.isPublished,
|
||||
albumIds: selectedAlbums.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('videos', id.value, payload)
|
||||
else await adminCreate('videos', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel() {
|
||||
return categories.value.find(c => String(c.id) === String(form.categoryId))?.name || '选择分类'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.title" label="标题" clearable />
|
||||
<wd-picker
|
||||
:columns="[categories.map(c => ({ label: c.name, value: c.id }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.categoryId = (value?.[0] as { value?: number })?.value ?? value?.[0] as number }"
|
||||
>
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ catLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<AdminMediaPicker v-model="form.videoUrl" label="视频文件" media-type="video" />
|
||||
<wd-input v-model="form.videoUrl" label="或填写视频 URL" clearable />
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<view class="switch-row">
|
||||
<text>发布</text>
|
||||
<wd-switch :model-value="!!form.isPublished" @change="(v: boolean) => form.isPublished = v ? 1 : 0" />
|
||||
</view>
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">所属专辑</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="a in albums"
|
||||
:key="a.id"
|
||||
class="tag"
|
||||
:class="{ on: selectedAlbums.includes(a.id) }"
|
||||
@click="toggleAlbum(a.id)"
|
||||
>
|
||||
{{ a.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); font-size: 26rpx; }
|
||||
.section { margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 18rpx; border-radius: 8rpx; font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
92
blog-wot-uniapp/src/subPackages/admin/pages/videos/list.vue
Normal file
92
blog-wot-uniapp/src/subPackages/admin/pages/videos/list.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频列表:标题/分类/发布状态。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminDelete, adminList, normalizeAdminList, type VideoItem } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const list = ref<VideoItem[]>([])
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = normalizeAdminList(await adminList('videos')) as VideoItem[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/videos/form?mode=create' })
|
||||
}
|
||||
function goEdit(item: VideoItem) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/videos/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
function onDelete(item: VideoItem) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除「${item.title}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('videos', item.id)
|
||||
toast.success('已删除')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="视频列表" show-back />
|
||||
<view class="page-pad">
|
||||
<view v-for="item in list" :key="String(item.id)" class="admin-card row" @click="goEdit(item)">
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<AdminStatusBadge
|
||||
:value="item.isPublished"
|
||||
:map="{ '1': '已发布', '0': '草稿', true: '已发布', false: '草稿' }"
|
||||
/>
|
||||
</view>
|
||||
<text class="sub text-art-muted">{{ item.categoryName || '未分类' }} · {{ item.createdAt || '' }}</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无视频" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate"><wd-icon name="plus" size="24px" color="#fff" /></view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; display: flex; align-items: center; gap: 16rpx; }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title { flex: 1; font-size: 30rpx; color: rgb(var(--art-text)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed; right: 40rpx; bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx; height: 96rpx; border-radius: 50%;
|
||||
background: rgb(var(--art-accent)); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
</style>
|
||||
211
blog-wot-uniapp/src/subPackages/admin/pages/works/form.vue
Normal file
211
blog-wot-uniapp/src/subPackages/admin/pages/works/form.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品表单:封面/图集/技术栈/外链/演示视频。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminUpdate, type Work, type WorkTechGroup } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
import { uploadAttachment } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
category: '',
|
||||
year: '',
|
||||
heroImg: '',
|
||||
heroVideo: '',
|
||||
desc: '',
|
||||
live: '',
|
||||
github: '',
|
||||
demo: '',
|
||||
})
|
||||
const techStack = ref<WorkTechGroup[]>([{ category: 'Frontend', items: [] }])
|
||||
const gallery = ref<string[]>([])
|
||||
const techInput = ref('')
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建作品' : '编辑作品'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
if (mode.value !== 'edit' || !id.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminGet('works', id.value) as Work
|
||||
form.title = String(data.title || '')
|
||||
form.category = String(data.category || '')
|
||||
form.year = String(data.year || '')
|
||||
form.heroImg = String(data.heroImg || '')
|
||||
form.heroVideo = String(data.heroVideo || data.videoUrl || '')
|
||||
form.desc = String(data.desc || data.description || '')
|
||||
const links = typeof data.links === 'string'
|
||||
? (() => { try { return JSON.parse(data.links) } catch { return {} } })()
|
||||
: (data.links || {})
|
||||
form.live = String((links as Record<string, string>).live || '')
|
||||
form.github = String((links as Record<string, string>).github || '')
|
||||
form.demo = String((links as Record<string, string>).demo || '')
|
||||
techStack.value = Array.isArray(data.techStack) && data.techStack.length
|
||||
? data.techStack
|
||||
: [{ category: 'Frontend', items: [] }]
|
||||
gallery.value = Array.isArray(data.gallery) ? [...data.gallery] : []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function addTechTag() {
|
||||
const t = techInput.value.trim()
|
||||
if (!t) return
|
||||
if (!techStack.value[0]) techStack.value = [{ category: 'Frontend', items: [] }]
|
||||
techStack.value[0].items = [...(techStack.value[0].items || []), t]
|
||||
techInput.value = ''
|
||||
}
|
||||
|
||||
function removeTech(i: number) {
|
||||
techStack.value[0]?.items?.splice(i, 1)
|
||||
}
|
||||
|
||||
async function addGallery() {
|
||||
if (gallery.value.length >= 20) {
|
||||
toast.show('最多 20 张')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await uni.chooseImage({ count: Math.min(9, 20 - gallery.value.length), sizeType: ['compressed'] })
|
||||
for (const path of res.tempFilePaths || []) {
|
||||
const up = await uploadAttachment(path)
|
||||
const url = up.fileUrl || up.url
|
||||
if (url) gallery.value.push(url)
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
category: form.category,
|
||||
year: form.year,
|
||||
heroImg: form.heroImg,
|
||||
heroVideo: form.heroVideo,
|
||||
desc: form.desc,
|
||||
techStack: techStack.value,
|
||||
gallery: gallery.value,
|
||||
links: { live: form.live, github: form.github, demo: form.demo },
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('works', id.value, payload)
|
||||
else await adminCreate('works', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.title" label="标题" clearable />
|
||||
<wd-input v-model="form.category" label="分类" clearable />
|
||||
<wd-input v-model="form.year" label="年份" clearable />
|
||||
<AdminMediaPicker v-model="form.heroImg" label="封面图" />
|
||||
<AdminMediaPicker v-model="form.heroVideo" label="演示视频" media-type="video" />
|
||||
<wd-textarea v-model="form.desc" label="描述" />
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">技术栈</text>
|
||||
<view class="tag-wrap">
|
||||
<view v-for="(t, i) in techStack[0]?.items || []" :key="i" class="tag" @click="removeTech(i)">{{ t }} ×</view>
|
||||
</view>
|
||||
<view class="row-input">
|
||||
<wd-input v-model="techInput" placeholder="添加技术标签" clearable />
|
||||
<wd-button size="small" @click="addTechTag">添加</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">图集 ({{ gallery.length }}/20)</text>
|
||||
<view class="gallery">
|
||||
<image v-for="(g, i) in gallery" :key="i" class="g-img" :src="resolveMediaUrl(g)" mode="aspectFill" @click="gallery.splice(i, 1)" />
|
||||
<view class="g-add" @click="addGallery">+</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-input v-model="form.live" label="线上地址" clearable />
|
||||
<wd-input v-model="form.github" label="GitHub" clearable />
|
||||
<wd-input v-model="form.demo" label="Demo" clearable />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.section { margin: 20rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 10rpx; margin: 12rpx 0; }
|
||||
.tag {
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(212, 179, 131, 0.2);
|
||||
color: rgb(var(--art-accent));
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.row-input { display: flex; gap: 12rpx; align-items: center; }
|
||||
.gallery { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.g-img, .g-add {
|
||||
width: 140rpx; height: 140rpx; border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.g-add {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 48rpx; color: rgba(255, 255, 255, 0.4);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
126
blog-wot-uniapp/src/subPackages/admin/pages/works/list.vue
Normal file
126
blog-wot-uniapp/src/subPackages/admin/pages/works/list.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品列表:本地关键词 + 分类/年份筛选。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminDelete, adminList, normalizeAdminList, type Work } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const loading = ref(false)
|
||||
const all = ref<Work[]>([])
|
||||
const keyword = ref('')
|
||||
const category = ref('')
|
||||
const year = ref('')
|
||||
|
||||
const categories = computed(() => {
|
||||
const set = new Set(all.value.map(w => String(w.category || '')).filter(Boolean))
|
||||
return Array.from(set)
|
||||
})
|
||||
const years = computed(() => {
|
||||
const set = new Set(all.value.map(w => String(w.year || '')).filter(Boolean))
|
||||
return Array.from(set).sort().reverse()
|
||||
})
|
||||
|
||||
const list = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
return all.value.filter((w) => {
|
||||
if (kw && !String(w.title || '').toLowerCase().includes(kw)) return false
|
||||
if (category.value && String(w.category) !== category.value) return false
|
||||
if (year.value && String(w.year) !== year.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
all.value = normalizeAdminList(await adminList('works')) as Work[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/works/form?mode=create' })
|
||||
}
|
||||
function goEdit(item: Work) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/works/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
function onDelete(item: Work) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除「${item.title}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('works', item.id)
|
||||
toast.success('已删除')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="作品管理" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar v-model:keyword="keyword" placeholder="搜索作品" @search="() => {}" @reset="keyword = ''; category = ''; year = ''" />
|
||||
<view class="chips">
|
||||
<view class="chip" :class="{ on: !category }" @click="category = ''">全部分类</view>
|
||||
<view v-for="c in categories" :key="c" class="chip" :class="{ on: category === c }" @click="category = c">{{ c }}</view>
|
||||
</view>
|
||||
<view class="chips">
|
||||
<view class="chip" :class="{ on: !year }" @click="year = ''">全部年份</view>
|
||||
<view v-for="y in years" :key="y" class="chip" :class="{ on: year === y }" @click="year = y">{{ y }}</view>
|
||||
</view>
|
||||
<view v-for="item in list" :key="String(item.id)" class="admin-card row" @click="goEdit(item)">
|
||||
<view class="main">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text class="sub text-art-muted">{{ item.category || '—' }} · {{ item.year || '—' }}</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无作品" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate"><wd-icon name="plus" size="24px" color="#fff" /></view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 16rpx; }
|
||||
.chip {
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.chip.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; display: flex; align-items: center; gap: 16rpx; }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title { font-size: 30rpx; color: rgb(var(--art-text)); }
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed; right: 40rpx; bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx; height: 96rpx; border-radius: 50%;
|
||||
background: rgb(var(--art-accent)); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
</style>
|
||||
115
blog-wot-uniapp/src/subPackages/content/components/CodeBlock.vue
Normal file
115
blog-wot-uniapp/src/subPackages/content/components/CodeBlock.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* C 端代码块:hljs 高亮后用 mp-html 渲染,样式内联适配暗色。
|
||||
*/
|
||||
import { computed, watch } from 'vue'
|
||||
import { highlightCodeBlock, preloadMarkdownRenderer } from '../lib/markdownRenderer'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
code?: string
|
||||
language?: string
|
||||
}>(), {
|
||||
code: '',
|
||||
language: '',
|
||||
})
|
||||
|
||||
preloadMarkdownRenderer()
|
||||
|
||||
const html = computed(() => highlightCodeBlock(props.code || '(empty)', props.language))
|
||||
|
||||
watch(() => props.code, () => {
|
||||
/* 触发 computed 刷新即可 */
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="code-block">
|
||||
<mp-html :content="html" container-style="overflow-x:auto;" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* mp-html 内样式有限,用 :deep 尽量覆盖;主要靠内联 class */
|
||||
.code-block {
|
||||
width: 100%;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
:deep(.ios-code-container) {
|
||||
background: #1e1e1e;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.mac-window-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 24rpx;
|
||||
background: #282828;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
:deep(.mac-dots) { display: flex; gap: 10rpx; }
|
||||
:deep(.dot) {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
:deep(.dot.red) { background: #ff5f56; }
|
||||
:deep(.dot.yellow) { background: #ffbd2e; }
|
||||
:deep(.dot.green) { background: #27c93f; }
|
||||
:deep(.mac-lang) {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
:deep(.code-pre) {
|
||||
margin: 0;
|
||||
padding: 24rpx;
|
||||
overflow-x: auto;
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
:deep(.hljs) {
|
||||
background: transparent !important;
|
||||
color: #abb2bf;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
white-space: pre;
|
||||
}
|
||||
/* atom-one-dark 精简 token */
|
||||
:deep(.hljs-comment),
|
||||
:deep(.hljs-quote) { color: #5c6370; font-style: italic; }
|
||||
:deep(.hljs-keyword),
|
||||
:deep(.hljs-selector-tag),
|
||||
:deep(.hljs-subst) { color: #c678dd; }
|
||||
:deep(.hljs-number),
|
||||
:deep(.hljs-literal),
|
||||
:deep(.hljs-variable),
|
||||
:deep(.hljs-template-variable),
|
||||
:deep(.hljs-tag .hljs-attr) { color: #d19a66; }
|
||||
:deep(.hljs-string),
|
||||
:deep(.hljs-doctag) { color: #98c379; }
|
||||
:deep(.hljs-title),
|
||||
:deep(.hljs-section),
|
||||
:deep(.hljs-selector-id) { color: #e06c75; }
|
||||
:deep(.hljs-type),
|
||||
:deep(.hljs-class .hljs-title) { color: #e5c07b; }
|
||||
:deep(.hljs-tag),
|
||||
:deep(.hljs-name),
|
||||
:deep(.hljs-attribute) { color: #e06c75; }
|
||||
:deep(.hljs-regexp),
|
||||
:deep(.hljs-link) { color: #56b6c2; }
|
||||
:deep(.hljs-symbol),
|
||||
:deep(.hljs-bullet) { color: #61afef; }
|
||||
:deep(.hljs-built_in),
|
||||
:deep(.hljs-builtin-name) { color: #e6c07b; }
|
||||
:deep(.hljs-meta) { color: #61afef; }
|
||||
:deep(.hljs-deletion) { background: #392b2b; }
|
||||
:deep(.hljs-addition) { background: #2b392f; }
|
||||
:deep(.hljs-emphasis) { font-style: italic; }
|
||||
:deep(.hljs-strong) { font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* content 分包通用页头:eyebrow + 标题 + 可选说明。
|
||||
* 仅供 content 分包页面复用,与主包 ProfileHero 职责分离(微信分包隔离)。
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
eyebrow?: string
|
||||
title: string
|
||||
desc?: string
|
||||
}>(), {
|
||||
eyebrow: '',
|
||||
desc: '',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="content-page-header animate-reveal">
|
||||
<view v-if="eyebrow" class="hero-eyebrow">{{ eyebrow }}</view>
|
||||
<view class="section-title">{{ title }}</view>
|
||||
<text v-if="desc" class="desc text-art-muted">{{ desc }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.content-page-header {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
.desc {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理员账号密码表单(content 分包内复用)。
|
||||
* 提交逻辑由父页处理;此处只做受控输入与 loading 按钮。
|
||||
*/
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
loading?: boolean
|
||||
username?: string
|
||||
password?: string
|
||||
/** 提交按钮文案 */
|
||||
submitLabel?: string
|
||||
}>(), {
|
||||
loading: false,
|
||||
username: '',
|
||||
password: '',
|
||||
submitLabel: '登录',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { username: string, password: string }]
|
||||
'update:username': [v: string]
|
||||
'update:password': [v: string]
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
username: props.username,
|
||||
password: props.password,
|
||||
})
|
||||
|
||||
watch(() => props.username, (v) => { form.username = v })
|
||||
watch(() => props.password, (v) => { form.password = v })
|
||||
|
||||
/** 同步账号到父组件(v-model) */
|
||||
const onUserInput = (v: string) => {
|
||||
form.username = v
|
||||
emit('update:username', v)
|
||||
}
|
||||
|
||||
/** 同步密码到父组件 */
|
||||
const onPassInput = (v: string) => {
|
||||
form.password = v
|
||||
emit('update:password', v)
|
||||
}
|
||||
|
||||
/** 点击登录:把当前表单抛给父页发请求 */
|
||||
const onSubmit = () => {
|
||||
emit('submit', { username: form.username, password: form.password })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="staff-login-form">
|
||||
<wd-form :model="form">
|
||||
<wd-input
|
||||
:model-value="form.username"
|
||||
label="账号"
|
||||
placeholder="用户名"
|
||||
clearable
|
||||
@update:model-value="onUserInput"
|
||||
/>
|
||||
<wd-input
|
||||
:model-value="form.password"
|
||||
label="密码"
|
||||
placeholder="密码"
|
||||
show-password
|
||||
clearable
|
||||
@update:model-value="onPassInput"
|
||||
/>
|
||||
</wd-form>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
custom-style="margin-top:24px;background:#d4b383;border-color:#d4b383;color:#171717;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.staff-login-form {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
24
blog-wot-uniapp/src/subPackages/content/lib/codeHighlight.ts
Normal file
24
blog-wot-uniapp/src/subPackages/content/lib/codeHighlight.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 代码语言别名:对齐 PC client/src/utils/codeHighlight.ts
|
||||
* 放在 content/lib,避免分包 utils 与主包 utils 路径冲突。
|
||||
*/
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
sh: 'bash',
|
||||
yml: 'yaml',
|
||||
md: 'markdown',
|
||||
xml: 'xml',
|
||||
svg: 'xml',
|
||||
noise: 'xml',
|
||||
}
|
||||
|
||||
/** 把业务侧语言名解析成 highlight.js 注册名 */
|
||||
export function resolveHighlightLanguage(typeName?: string): string {
|
||||
if (!typeName) return 'plaintext'
|
||||
const key = typeName.trim().toLowerCase()
|
||||
if (!key) return 'plaintext'
|
||||
return LANGUAGE_ALIASES[key] || key
|
||||
}
|
||||
120
blog-wot-uniapp/src/subPackages/content/lib/markdownRenderer.ts
Normal file
120
blog-wot-uniapp/src/subPackages/content/lib/markdownRenderer.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Markdown 渲染:marked + 树摇 highlight.js。
|
||||
* 放在 content/lib(勿用分包 utils,会与主包 utils 撞路径导致 ENOENT)。
|
||||
* 不做行号(对齐 PC 移动端)。
|
||||
*/
|
||||
import { marked, type Tokens } from 'marked'
|
||||
import type { HLJSApi, LanguageFn } from 'highlight.js'
|
||||
import hljsCore from 'highlight.js/lib/core'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import go from 'highlight.js/lib/languages/go'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import sql from 'highlight.js/lib/languages/sql'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
import { resolveHighlightLanguage } from './codeHighlight'
|
||||
import { rewriteHtmlMediaUrls } from '@/utils/request'
|
||||
|
||||
const LANGUAGE_REGISTRY: Array<[string, LanguageFn]> = [
|
||||
['javascript', javascript],
|
||||
['typescript', typescript],
|
||||
['python', python],
|
||||
['go', go],
|
||||
['java', java],
|
||||
['css', css],
|
||||
['xml', xml],
|
||||
['html', xml],
|
||||
['json', json],
|
||||
['bash', bash],
|
||||
['sh', bash],
|
||||
['sql', sql],
|
||||
['yaml', yaml],
|
||||
['yml', yaml],
|
||||
['markdown', markdown],
|
||||
['md', markdown],
|
||||
]
|
||||
|
||||
let hljsInstance: HLJSApi | null = null
|
||||
let markedReady = false
|
||||
|
||||
function getHljs(): HLJSApi {
|
||||
if (!hljsInstance) {
|
||||
hljsInstance = hljsCore
|
||||
for (const [name, register] of LANGUAGE_REGISTRY) {
|
||||
hljsInstance.registerLanguage(name, register)
|
||||
}
|
||||
}
|
||||
return hljsInstance
|
||||
}
|
||||
|
||||
/** 转义 HTML,避免未高亮代码注入 */
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* 单段代码 → 带 Mac 风格头栏的 HTML(供 mp-html)
|
||||
*/
|
||||
export function highlightCodeBlock(code: string, language?: string): string {
|
||||
const hljs = getHljs()
|
||||
const lang = resolveHighlightLanguage(language)
|
||||
let highlighted: string
|
||||
if (lang && lang !== 'plaintext' && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
|
||||
}
|
||||
catch {
|
||||
highlighted = escapeHtml(code)
|
||||
}
|
||||
}
|
||||
else {
|
||||
highlighted = escapeHtml(code)
|
||||
}
|
||||
const label = (lang && lang !== 'plaintext' ? lang : 'TEXT').toUpperCase()
|
||||
return `
|
||||
<div class="ios-code-container">
|
||||
<div class="mac-window-header">
|
||||
<div class="mac-dots">
|
||||
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
|
||||
</div>
|
||||
<span class="mac-lang">${label}</span>
|
||||
</div>
|
||||
<pre class="hljs code-pre"><code class="hljs">${highlighted}</code></pre>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function ensureMarked() {
|
||||
if (markedReady) return
|
||||
const renderer = new marked.Renderer()
|
||||
renderer.code = ({ text, lang }: Tokens.Code) => highlightCodeBlock(text, lang || '')
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
})
|
||||
marked.use({ renderer })
|
||||
markedReady = true
|
||||
}
|
||||
|
||||
/** 渲染 Markdown 为 HTML(含代码高亮块);相对图片路径拼上传域名 */
|
||||
export async function renderMarkdown(content: string): Promise<string> {
|
||||
if (!content) return ''
|
||||
getHljs()
|
||||
ensureMarked()
|
||||
const html = marked.parse(content) as string
|
||||
return rewriteHtmlMediaUrls(html)
|
||||
}
|
||||
|
||||
/** 预加载 highlight(进入详情/弹层前可调用) */
|
||||
export function preloadMarkdownRenderer(): void {
|
||||
getHljs()
|
||||
}
|
||||
214
blog-wot-uniapp/src/subPackages/content/pages/about/index.vue
Normal file
214
blog-wot-uniapp/src/subPackages/content/pages/about/index.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 关于页:档案、联系、技术栈、经历时间轴(对齐 PC About 信息架构)。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import { getAbout, type AboutProfile } from '@/api'
|
||||
import { resolveMediaUrl, rewriteHtmlMediaUrls } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const profile = ref<AboutProfile | null>(null)
|
||||
|
||||
function normalizeProfile(data: AboutProfile | AboutProfile[] | null): AboutProfile | null {
|
||||
if (!data) return null
|
||||
if (Array.isArray(data)) return data[0] || null
|
||||
return data
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getAbout()
|
||||
profile.value = normalizeProfile(data as AboutProfile | AboutProfile[])
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
const avatarUrl = computed(() => resolveMediaUrl(String(profile.value?.avatar || '')))
|
||||
const techList = computed(() => {
|
||||
const t = profile.value?.techStack
|
||||
return Array.isArray(t) ? t.map(String) : []
|
||||
})
|
||||
const experiences = computed(() => profile.value?.experiences || [])
|
||||
const bioHtml = computed(() => rewriteHtmlMediaUrls(String(profile.value?.bio || '')))
|
||||
|
||||
const copyText = (label: string, value?: string) => {
|
||||
const v = String(value || '').trim()
|
||||
if (!v) return
|
||||
uni.setClipboardData({
|
||||
data: v,
|
||||
success: () => uni.showToast({ title: `${label}已复制`, icon: 'none' }),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="关于" show-back />
|
||||
<view class="page-pad">
|
||||
<view v-if="profile" class="animate-reveal">
|
||||
<ContentPageHeader eyebrow="个人档案" title="关于我" />
|
||||
|
||||
<view class="identity">
|
||||
<image
|
||||
v-if="avatarUrl"
|
||||
class="avatar"
|
||||
:src="avatarUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="identity-meta">
|
||||
<text class="name">{{ profile.name || '关于我' }}</text>
|
||||
<view v-if="profile.location" class="loc text-art-muted">
|
||||
<wd-icon name="location" size="14px" color="rgb(212,179,131)" />
|
||||
<text>{{ profile.location }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<rich-text v-if="bioHtml" class="bio" :nodes="bioHtml" />
|
||||
|
||||
<view v-if="profile.email || profile.wechat" class="art-card contact">
|
||||
<text class="block-title">联系方式</text>
|
||||
<view
|
||||
v-if="profile.email"
|
||||
class="contact-row"
|
||||
@click="copyText('邮箱', profile.email)"
|
||||
>
|
||||
<wd-icon name="email" size="18px" color="rgb(212,179,131)" />
|
||||
<text class="contact-text">{{ profile.email }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="profile.wechat"
|
||||
class="contact-row"
|
||||
@click="copyText('微信', profile.wechat)"
|
||||
>
|
||||
<wd-icon name="message" size="18px" color="rgb(212,179,131)" />
|
||||
<text class="contact-text">WeChat: {{ profile.wechat }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="techList.length" class="art-card panel">
|
||||
<text class="block-title">技术栈</text>
|
||||
<view class="pills">
|
||||
<text v-for="(t, i) in techList" :key="i" class="pill">{{ t }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="experiences.length" class="art-card panel">
|
||||
<text class="block-title">经历</text>
|
||||
<view class="timeline">
|
||||
<view
|
||||
v-for="(exp, i) in experiences"
|
||||
:key="i"
|
||||
class="exp"
|
||||
>
|
||||
<view class="dot" :class="{ 'dot--first': i === 0 }" />
|
||||
<view class="exp-body">
|
||||
<text class="exp-year font-mono-label" :class="i === 0 ? 'text-art-accent' : 'text-art-muted'">
|
||||
{{ exp.year }}
|
||||
</text>
|
||||
<text class="exp-role">{{ exp.role }}</text>
|
||||
<text class="exp-company text-art-muted">{{ exp.company }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !profile" description="暂无资料" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 144rpx;
|
||||
height: 144rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid rgba(212, 179, 131, 0.55);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.identity-meta { display: flex; flex-direction: column; gap: 12rpx; min-width: 0; }
|
||||
.name {
|
||||
font-size: 40rpx;
|
||||
color: rgb(var(--art-text));
|
||||
font-weight: 600;
|
||||
}
|
||||
.loc { display: flex; align-items: center; gap: 8rpx; font-size: 26rpx; }
|
||||
.bio {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.8;
|
||||
color: rgb(var(--art-muted));
|
||||
margin-bottom: 36rpx;
|
||||
}
|
||||
.contact, .panel {
|
||||
padding: 28rpx 32rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.block-title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.contact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 14rpx 0;
|
||||
}
|
||||
.contact-text { font-size: 26rpx; color: rgb(var(--art-muted)); }
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.pill {
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
border: 1px solid var(--art-card-border);
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
background: rgba(212, 179, 131, 0.06);
|
||||
}
|
||||
.timeline {
|
||||
border-left: 2rpx solid var(--art-card-border);
|
||||
margin-left: 10rpx;
|
||||
padding-left: 28rpx;
|
||||
}
|
||||
.exp {
|
||||
position: relative;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.dot {
|
||||
position: absolute;
|
||||
left: -36rpx;
|
||||
top: 10rpx;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(127, 127, 127, 0.35);
|
||||
}
|
||||
.dot--first { background: rgb(var(--art-accent)); }
|
||||
.exp-body { display: flex; flex-direction: column; gap: 6rpx; }
|
||||
.exp-year { font-size: 22rpx; }
|
||||
.exp-role { font-size: 28rpx; color: rgb(var(--art-text)); font-weight: 600; }
|
||||
.exp-company { font-size: 24rpx; }
|
||||
</style>
|
||||
368
blog-wot-uniapp/src/subPackages/content/pages/blog/detail.vue
Normal file
368
blog-wot-uniapp/src/subPackages/content/pages/blog/detail.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 思考详情:正文 + 分类/专栏/标签导航 + 专栏其它文章 + 相关推荐。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import PostCard from '@/components/c-end/PostCard.vue'
|
||||
import {
|
||||
getColumn,
|
||||
getColumnPosts,
|
||||
getPost,
|
||||
getRecommendedPosts,
|
||||
type Column,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
import { renderMarkdown, preloadMarkdownRenderer } from '../../lib/markdownRenderer'
|
||||
|
||||
const postId = ref('')
|
||||
const post = ref<Post | null>(null)
|
||||
const html = ref('')
|
||||
const loading = ref(false)
|
||||
const recommended = ref<Post[]>([])
|
||||
const columnInfo = ref<Column | null>(null)
|
||||
const columnPosts = ref<Post[]>([])
|
||||
|
||||
const DEFAULT_AVATAR = '/static/default-avatar.svg'
|
||||
|
||||
const avatarSrc = computed(() => {
|
||||
const raw = post.value?.userAvatar
|
||||
if (!raw) return DEFAULT_AVATAR
|
||||
return resolveMediaUrl(String(raw)) || DEFAULT_AVATAR
|
||||
})
|
||||
|
||||
const tagList = computed(() => {
|
||||
const tags = post.value?.tags
|
||||
if (!Array.isArray(tags)) return [] as { id?: number, name: string }[]
|
||||
return tags.map((t) => {
|
||||
if (typeof t === 'string') return { name: t }
|
||||
return { id: t?.id, name: String(t?.name || '') }
|
||||
}).filter(t => t.name)
|
||||
})
|
||||
|
||||
const columnName = computed(() => {
|
||||
if (columnInfo.value?.name || columnInfo.value?.title) {
|
||||
return String(columnInfo.value.name || columnInfo.value.title)
|
||||
}
|
||||
return String(post.value?.columnName || '')
|
||||
})
|
||||
|
||||
const otherColumnPosts = computed(() =>
|
||||
columnPosts.value
|
||||
.filter(p => String(p.id) !== String(postId.value))
|
||||
.slice(0, 8),
|
||||
)
|
||||
|
||||
/** 按 id 拉文章、推荐、专栏列表并渲染 Markdown */
|
||||
async function loadDetail() {
|
||||
if (!postId.value) return
|
||||
loading.value = true
|
||||
preloadMarkdownRenderer()
|
||||
try {
|
||||
const postData = await getPost(postId.value)
|
||||
post.value = postData
|
||||
html.value = await renderMarkdown(String(postData?.content || ''))
|
||||
|
||||
const tasks: Promise<void>[] = [
|
||||
getRecommendedPosts(postId.value, 3)
|
||||
.then((list) => { recommended.value = Array.isArray(list) ? list : [] })
|
||||
.catch(() => { recommended.value = [] }),
|
||||
]
|
||||
|
||||
const colId = postData?.columnId as number | undefined
|
||||
if (colId) {
|
||||
tasks.push(
|
||||
Promise.all([
|
||||
getColumn(colId).catch(() => null),
|
||||
getColumnPosts(colId).catch(() => []),
|
||||
]).then(([col, posts]) => {
|
||||
columnInfo.value = col
|
||||
columnPosts.value = Array.isArray(posts) ? posts : []
|
||||
}),
|
||||
)
|
||||
}
|
||||
else {
|
||||
columnInfo.value = null
|
||||
columnPosts.value = []
|
||||
}
|
||||
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
postId.value = String(q?.id || '')
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadDetail()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
function goCategory() {
|
||||
const id = post.value?.categoryId
|
||||
const name = post.value?.categoryName
|
||||
if (id) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/content/pages/blog/index?categoryId=${id}&categoryName=${encodeURIComponent(String(name || ''))}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: '/subPackages/content/pages/blog/index' })
|
||||
}
|
||||
|
||||
function goColumn() {
|
||||
const id = post.value?.columnId
|
||||
if (!id) return
|
||||
uni.navigateTo({ url: `/subPackages/content/pages/columns/detail?id=${id}` })
|
||||
}
|
||||
|
||||
function goTag(tag: { id?: number, name: string }) {
|
||||
const q = tag.id
|
||||
? `tagId=${tag.id}&tagName=${encodeURIComponent(tag.name)}`
|
||||
: `tagName=${encodeURIComponent(tag.name)}`
|
||||
uni.navigateTo({ url: `/subPackages/content/pages/blog/index?${q}` })
|
||||
}
|
||||
|
||||
function goPost(id: string | number) {
|
||||
uni.redirectTo({ url: `/subPackages/content/pages/blog/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar :title="post?.title || '思考详情'" show-back />
|
||||
<view class="page-pad">
|
||||
<wd-loading v-if="loading" />
|
||||
<template v-else-if="post">
|
||||
<view class="meta-row">
|
||||
<text
|
||||
v-if="post.categoryName"
|
||||
class="chip text-art-accent"
|
||||
@click="goCategory"
|
||||
>
|
||||
{{ post.categoryName }}
|
||||
</text>
|
||||
<text
|
||||
v-if="columnName"
|
||||
class="chip text-art-accent"
|
||||
@click="goColumn"
|
||||
>
|
||||
{{ columnName }}
|
||||
</text>
|
||||
<text v-if="post.date" class="font-mono-label text-art-muted">{{ post.date }}</text>
|
||||
</view>
|
||||
|
||||
<text class="title">{{ post.title }}</text>
|
||||
|
||||
<view v-if="post.userName" class="author">
|
||||
<image class="avatar" :src="avatarSrc" mode="aspectFill" />
|
||||
<text class="name">{{ post.userName }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="tagList.length" class="tags">
|
||||
<view
|
||||
v-for="(t, i) in tagList"
|
||||
:key="`${t.id || t.name}-${i}`"
|
||||
class="tag"
|
||||
@click="goTag(t)"
|
||||
>
|
||||
#{{ t.name }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="article-md body">
|
||||
<mp-html :content="html" container-style="overflow-x:auto;" />
|
||||
</view>
|
||||
|
||||
<view v-if="otherColumnPosts.length" class="section">
|
||||
<view class="section-head">
|
||||
<text class="section-title">专栏其它文章</text>
|
||||
<text v-if="post.columnId" class="more text-art-accent" @click="goColumn">全部 ›</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in otherColumnPosts"
|
||||
:key="String(item.id)"
|
||||
class="col-row"
|
||||
@click="goPost(item.id)"
|
||||
>
|
||||
<text class="col-title">{{ item.title }}</text>
|
||||
<text class="col-date text-art-muted">{{ item.date || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="recommended.length" class="section">
|
||||
<text class="section-title">相关推荐</text>
|
||||
<PostCard
|
||||
v-for="item in recommended"
|
||||
:key="String(item.id)"
|
||||
:post="item"
|
||||
@click="goPost(item.id)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<wd-empty v-else-if="!loading" description="文章不存在" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 64rpx; display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.chip {
|
||||
font-size: 24rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(212, 179, 131, 0.12);
|
||||
}
|
||||
.title {
|
||||
font-size: 44rpx;
|
||||
line-height: 1.35;
|
||||
color: rgb(var(--art-text));
|
||||
font-family: 'Playfair Display', serif;
|
||||
}
|
||||
.author { display: flex; align-items: center; gap: 12rpx; margin-bottom: 8rpx; }
|
||||
.avatar {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
.name { font-size: 26rpx; color: rgb(var(--art-text)); }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.tag {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
color: rgb(var(--art-muted));
|
||||
background: rgba(127, 127, 127, 0.1);
|
||||
}
|
||||
.body { margin-top: 12rpx; }
|
||||
.section {
|
||||
margin-top: 40rpx;
|
||||
padding-top: 28rpx;
|
||||
border-top: 1px solid var(--art-border);
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.section-title {
|
||||
font-family: 'Playfair Display', 'Noto Serif SC', serif;
|
||||
font-style: italic;
|
||||
font-size: 36rpx;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.section-head .section-title { margin-bottom: 0; }
|
||||
.more { font-size: 24rpx; }
|
||||
.col-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1px solid var(--art-border);
|
||||
}
|
||||
.col-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-date { font-size: 22rpx; flex-shrink: 0; }
|
||||
:deep(.ios-code-container) {
|
||||
background: #1e1e1e;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
margin: 24rpx 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
:deep(.mac-window-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 24rpx;
|
||||
background: #282828;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
:deep(.mac-dots) { display: flex; gap: 10rpx; }
|
||||
:deep(.dot) {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
:deep(.dot.red) { background: #ff5f56; }
|
||||
:deep(.dot.yellow) { background: #ffbd2e; }
|
||||
:deep(.dot.green) { background: #27c93f; }
|
||||
:deep(.mac-lang) {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
:deep(.code-pre) {
|
||||
margin: 0;
|
||||
padding: 24rpx;
|
||||
overflow-x: auto;
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
:deep(.hljs) {
|
||||
background: transparent !important;
|
||||
color: #abb2bf;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
white-space: pre;
|
||||
}
|
||||
:deep(.hljs-comment),
|
||||
:deep(.hljs-quote) { color: #5c6370; font-style: italic; }
|
||||
:deep(.hljs-keyword),
|
||||
:deep(.hljs-selector-tag),
|
||||
:deep(.hljs-subst) { color: #c678dd; }
|
||||
:deep(.hljs-number),
|
||||
:deep(.hljs-literal),
|
||||
:deep(.hljs-variable),
|
||||
:deep(.hljs-template-variable),
|
||||
:deep(.hljs-tag .hljs-attr) { color: #d19a66; }
|
||||
:deep(.hljs-string),
|
||||
:deep(.hljs-doctag) { color: #98c379; }
|
||||
:deep(.hljs-title),
|
||||
:deep(.hljs-section),
|
||||
:deep(.hljs-selector-id) { color: #e06c75; }
|
||||
:deep(.hljs-type),
|
||||
:deep(.hljs-class .hljs-title) { color: #e5c07b; }
|
||||
:deep(.hljs-tag),
|
||||
:deep(.hljs-name),
|
||||
:deep(.hljs-attribute) { color: #e06c75; }
|
||||
:deep(.hljs-regexp),
|
||||
:deep(.hljs-link) { color: #56b6c2; }
|
||||
:deep(.hljs-symbol),
|
||||
:deep(.hljs-bullet) { color: #61afef; }
|
||||
:deep(.hljs-built_in),
|
||||
:deep(.hljs-builtin-name) { color: #e6c07b; }
|
||||
:deep(.hljs-meta) { color: #61afef; }
|
||||
</style>
|
||||
239
blog-wot-uniapp/src/subPackages/content/pages/blog/index.vue
Normal file
239
blog-wot-uniapp/src/subPackages/content/pages/blog/index.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 思考列表:PostCard + 暗色搜索条 + 历史/热搜面板。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import PostCard from '@/components/c-end/PostCard.vue'
|
||||
import ArtSearchBar from '@/components/c-end/ArtSearchBar.vue'
|
||||
import { getPosts, getHotSearches, type Post } from '@/api'
|
||||
import { useSearchHistory } from '@/composables/useSearchHistory'
|
||||
import { showUnsupported } from '@/utils/unsupported'
|
||||
|
||||
const { getHistory, addHistory, removeHistory, clearHistory } = useSearchHistory()
|
||||
|
||||
const q = ref('')
|
||||
const list = ref<Post[]>([])
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const hot = ref<string[]>([])
|
||||
const history = ref<string[]>([])
|
||||
const showSuggest = ref(false)
|
||||
/** 从详情页跳转带来的筛选 */
|
||||
const filterCategoryId = ref('')
|
||||
const filterTagId = ref('')
|
||||
const filterTagName = ref('')
|
||||
const filterLabel = ref('')
|
||||
|
||||
function refreshHistory() {
|
||||
history.value = getHistory()
|
||||
}
|
||||
|
||||
async function fetchList(reset = false) {
|
||||
if (loading.value) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
if (finished.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const keyword = q.value.trim()
|
||||
const res = await getPosts({
|
||||
...(keyword ? { q: keyword } : {}),
|
||||
...(filterCategoryId.value ? { categoryId: filterCategoryId.value } : {}),
|
||||
...(filterTagId.value ? { tagId: filterTagId.value } : {}),
|
||||
...(filterTagName.value && !filterTagId.value ? { tag: filterTagName.value } : {}),
|
||||
page: page.value,
|
||||
pageSize: 10,
|
||||
})
|
||||
const items = Array.isArray(res) ? res : (res as { list?: Post[] }).list || []
|
||||
list.value = reset ? items : [...list.value, ...items]
|
||||
if (items.length < 10) finished.value = true
|
||||
else page.value += 1
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (query) => {
|
||||
filterCategoryId.value = String(query?.categoryId || '')
|
||||
filterTagId.value = String(query?.tagId || '')
|
||||
filterTagName.value = query?.tagName ? decodeURIComponent(String(query.tagName)) : ''
|
||||
const catName = query?.categoryName ? decodeURIComponent(String(query.categoryName)) : ''
|
||||
if (catName) filterLabel.value = `分类:${catName}`
|
||||
else if (filterTagName.value) filterLabel.value = `标签:${filterTagName.value}`
|
||||
else filterLabel.value = ''
|
||||
|
||||
refreshHistory()
|
||||
fetchList(true)
|
||||
try {
|
||||
const h = await getHotSearches(8)
|
||||
hot.value = (h || []).map(i => i.keyword)
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
})
|
||||
|
||||
function clearFilter() {
|
||||
filterCategoryId.value = ''
|
||||
filterTagId.value = ''
|
||||
filterTagName.value = ''
|
||||
filterLabel.value = ''
|
||||
fetchList(true)
|
||||
}
|
||||
|
||||
onReachBottom(() => fetchList())
|
||||
|
||||
/** 提交搜索:写入历史并刷新列表 */
|
||||
const onSearch = () => {
|
||||
const kw = q.value.trim()
|
||||
if (kw) {
|
||||
addHistory(kw)
|
||||
refreshHistory()
|
||||
}
|
||||
showSuggest.value = false
|
||||
fetchList(true)
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
q.value = ''
|
||||
showSuggest.value = false
|
||||
fetchList(true)
|
||||
}
|
||||
|
||||
const applyKeyword = (kw: string) => {
|
||||
q.value = kw
|
||||
onSearch()
|
||||
}
|
||||
|
||||
const onRemoveHistory = (kw: string) => {
|
||||
removeHistory(kw)
|
||||
refreshHistory()
|
||||
}
|
||||
|
||||
const onClearHistory = () => {
|
||||
clearHistory()
|
||||
refreshHistory()
|
||||
}
|
||||
|
||||
const open = (id: string | number) =>
|
||||
uni.navigateTo({ url: `/subPackages/content/pages/blog/detail?id=${id}` })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="思考" show-back />
|
||||
<view class="page-pad">
|
||||
<ContentPageHeader eyebrow="Think" title="思考" desc="记录与沉淀" />
|
||||
<ArtSearchBar
|
||||
v-model="q"
|
||||
placeholder="搜索文章"
|
||||
@search="onSearch"
|
||||
@clear="onClear"
|
||||
@focus="showSuggest = true; refreshHistory()"
|
||||
/>
|
||||
|
||||
<view v-if="filterLabel" class="filter-bar">
|
||||
<text class="filter-text text-art-accent">{{ filterLabel }}</text>
|
||||
<text class="filter-clear text-art-muted" @click="clearFilter">清除</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showSuggest && (history.length || hot.length)" class="suggest art-card">
|
||||
<view v-if="history.length" class="block">
|
||||
<view class="block-head">
|
||||
<text class="font-mono-label text-art-muted">搜索历史</text>
|
||||
<text class="clear-all text-art-muted" @click="onClearHistory">清空</text>
|
||||
</view>
|
||||
<view class="chips">
|
||||
<view v-for="k in history" :key="`h-${k}`" class="chip" @click="applyKeyword(k)">
|
||||
<text class="chip-text">{{ k }}</text>
|
||||
<view class="chip-x" @click.stop="onRemoveHistory(k)">
|
||||
<wd-icon name="close" size="12px" color="rgb(136,136,136)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hot.length" class="block">
|
||||
<text class="font-mono-label text-art-muted">热搜</text>
|
||||
<view class="chips">
|
||||
<view v-for="k in hot" :key="`hot-${k}`" class="chip chip--hot" @click="applyKeyword(k)">
|
||||
<text class="chip-text">{{ k }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="hot.length && !q" class="hot-inline">
|
||||
<text class="font-mono-label text-art-muted">热搜</text>
|
||||
<view class="chips">
|
||||
<view v-for="k in hot" :key="k" class="chip chip--hot" @click="applyKeyword(k)">
|
||||
<text class="chip-text">{{ k }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<PostCard
|
||||
v-for="item in list"
|
||||
:key="String(item.id)"
|
||||
:post="item"
|
||||
@click="open(item.id)"
|
||||
/>
|
||||
<wd-loadmore v-if="list.length" :state="finished ? 'finished' : loading ? 'loading' : 'finished'" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无文章" />
|
||||
<view class="export-tip text-art-muted" @click="showUnsupported('文章批量导出')">导出功能 ›</view>
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16rpx 0 8rpx;
|
||||
padding: 14rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
}
|
||||
.filter-text { font-size: 24rpx; }
|
||||
.filter-clear { font-size: 22rpx; }
|
||||
.suggest {
|
||||
margin: 20rpx 0 28rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
.hot-inline { margin: 20rpx 0 28rpx; }
|
||||
.block { display: flex; flex-direction: column; gap: 14rpx; }
|
||||
.block-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.clear-all { font-size: 22rpx; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(127, 127, 127, 0.1);
|
||||
border: 1px solid var(--art-border);
|
||||
}
|
||||
.chip--hot { border-color: rgba(212, 179, 131, 0.35); }
|
||||
.chip-text { font-size: 24rpx; color: rgb(var(--art-text)); }
|
||||
.chip-x { display: flex; align-items: center; padding: 2rpx; }
|
||||
.export-tip { text-align: center; font-size: 24rpx; margin-top: 24rpx; }
|
||||
</style>
|
||||
60
blog-wot-uniapp/src/subPackages/content/pages/blog/ppt.vue
Normal file
60
blog-wot-uniapp/src/subPackages/content/pages/blog/ppt.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { getPost, type Post } from '@/api'
|
||||
import { showUnsupported } from '@/utils/unsupported'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const rootClass = computed(() => ['page-root', isDark.value ? 'theme-dark' : ''])
|
||||
const postId = ref('')
|
||||
const post = ref<Post | null>(null)
|
||||
|
||||
/** 拉文章用于 PPT 预览入口 */
|
||||
async function loadDetail() {
|
||||
if (!postId.value) return
|
||||
try {
|
||||
post.value = await getPost(postId.value)
|
||||
if (!(post.value as { presentation?: unknown }).presentation) {
|
||||
showUnsupported('PPT 预览')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
showUnsupported('PPT 预览')
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
postId.value = String(q?.id || '')
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadDetail()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view :class="rootClass">
|
||||
<AppNavbar title="PPT 预览" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="section-title">{{ post?.title || 'PPT' }}</view>
|
||||
<view class="tip text-art-muted">结构化 PPT 演示在小程序端能力受限。可点击下方按钮了解。</view>
|
||||
<wd-button block custom-style="margin-top:24px;background:#d4b383;border:none;" @click="showUnsupported('结构化 PPT / 导出')">
|
||||
打开完整 PPT 能力
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-toast /><wd-dialog />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-pad { padding: 32rpx; }
|
||||
.tip { margin-top: 24rpx; line-height: 1.7; font-size: 28rpx; }
|
||||
</style>
|
||||
104
blog-wot-uniapp/src/subPackages/content/pages/columns/detail.vue
Normal file
104
blog-wot-uniapp/src/subPackages/content/pages/columns/detail.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { getColumn, getColumnPosts, type Column, type Post } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const rootClass = computed(() => ['page-root', isDark.value ? 'theme-dark' : ''])
|
||||
|
||||
const id = ref('')
|
||||
const column = ref<Column | null>(null)
|
||||
const posts = ref<Post[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
/** 拉专栏信息与文章列表 */
|
||||
async function loadDetail() {
|
||||
if (!id.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [col, postList] = await Promise.all([
|
||||
getColumn(id.value),
|
||||
getColumnPosts(id.value),
|
||||
])
|
||||
column.value = col
|
||||
posts.value = postList || []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
id.value = String(q?.id || '')
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadDetail()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
const openPost = (pid: string | number) => uni.navigateTo({ url: `/subPackages/content/pages/blog/detail?id=${pid}` })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view :class="rootClass">
|
||||
<AppNavbar :title="column?.title || '专栏详情'" show-back />
|
||||
<view v-if="column" class="page-pad">
|
||||
<image
|
||||
v-if="column.cover"
|
||||
class="hero"
|
||||
:src="resolveMediaUrl(String(column.cover))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="hero-eyebrow">Column</view>
|
||||
<view class="section-title" style="margin-bottom: 16rpx;">{{ column.title }}</view>
|
||||
<text v-if="column.description" class="desc text-art-muted">{{ column.description }}</text>
|
||||
<view class="section-head">
|
||||
<text class="section-title" style="font-size: 34rpx;">文章列表</text>
|
||||
<text class="font-mono-label text-art-muted">{{ posts.length }} 篇</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in posts"
|
||||
:key="String(item.id)"
|
||||
class="art-card art-card-press card"
|
||||
@click="openPost(item.id)"
|
||||
>
|
||||
<text class="font-mono-label text-art-muted">{{ item.date || item.categoryName }}</text>
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text v-if="item.summary" class="summary text-art-muted">{{ item.summary }}</text>
|
||||
</view>
|
||||
<wd-empty v-if="!loading && !posts.length" description="暂无文章" />
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-toast />
|
||||
<wd-dialog />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.hero { width: 100%; height: 320rpx; border-radius: 16rpx; margin-bottom: 24rpx; }
|
||||
.desc { font-size: 28rpx; line-height: 1.7; display: block; margin-bottom: 32rpx; }
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
border-bottom: 1px solid var(--art-border);
|
||||
padding-bottom: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.card { padding: 28rpx 32rpx; margin-bottom: 20rpx; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.title { font-size: 32rpx; color: rgb(var(--art-text)); }
|
||||
.summary { font-size: 26rpx; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { getColumns, type Column } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Column[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = await getColumns() || []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
const open = (id: string | number) => uni.navigateTo({ url: `/subPackages/content/pages/columns/detail?id=${id}` })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="专栏" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="hero-eyebrow">Columns</view>
|
||||
<view class="section-title" style="margin-bottom: 32rpx;">专栏</view>
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="String(item.id)"
|
||||
class="art-card art-card-press card"
|
||||
@click="open(item.id)"
|
||||
>
|
||||
<image
|
||||
v-if="item.cover"
|
||||
class="cover"
|
||||
:src="resolveMediaUrl(String(item.cover))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="body">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text v-if="item.description" class="desc text-art-muted">{{ item.description }}</text>
|
||||
<text v-if="item.postCount != null" class="font-mono-label text-art-muted">{{ item.postCount }} 篇</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无专栏" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.card { margin-bottom: 24rpx; overflow: hidden; }
|
||||
.cover { width: 100%; height: 240rpx; display: block; }
|
||||
.body { padding: 28rpx 32rpx; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.title { font-size: 36rpx; color: rgb(var(--art-text)); font-family: 'Playfair Display', serif; }
|
||||
.desc { font-size: 26rpx; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
</style>
|
||||
133
blog-wot-uniapp/src/subPackages/content/pages/login/index.vue
Normal file
133
blog-wot-uniapp/src/subPackages/content/pages/login/index.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 独立管理员登录 / 合并账号页(content 分包)。
|
||||
* mode=merge:校验管理员密码,绑定微信并注销当前游客。
|
||||
* 默认 mode:普通账号密码登录。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import StaffLoginForm from '../../components/StaffLoginForm.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { loginApi, mergeAccountApi } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { login, isAuthenticated } = useAuth()
|
||||
const { setLoginType } = useAppMode()
|
||||
|
||||
const submitting = ref(false)
|
||||
const form = reactive({ username: '', password: '' })
|
||||
/** merge = 合并账号;login = 普通登录 */
|
||||
const mode = ref<'login' | 'merge'>('login')
|
||||
|
||||
const isMerge = computed(() => mode.value === 'merge')
|
||||
const pageTitle = computed(() => (isMerge.value ? '合并账号' : '登录'))
|
||||
const headerTitle = computed(() => (isMerge.value ? '合并到管理员' : '管理员登录'))
|
||||
const headerDesc = computed(() => (
|
||||
isMerge.value
|
||||
? '验证管理员账号密码后,当前游客微信将绑定到该账号,游客账号逻辑删除。'
|
||||
: '使用账号密码进入员工身份,仅管理员与编辑者可切换后台。'
|
||||
))
|
||||
const headerEyebrow = computed(() => (isMerge.value ? 'Merge Account' : 'Staff Login'))
|
||||
const hintText = computed(() => (
|
||||
isMerge.value
|
||||
? '合并需要已通过微信静默登录的游客身份。'
|
||||
: '账号仅用于站点管理,与微信静默登录相互独立。'
|
||||
))
|
||||
|
||||
onLoad((q) => {
|
||||
const m = String(q?.mode || '').toLowerCase()
|
||||
mode.value = m === 'merge' ? 'merge' : 'login'
|
||||
})
|
||||
|
||||
/** 成功后写 token 并返回「我的」 */
|
||||
const finishAuth = (res: { token: string, user: { role?: string, [k: string]: unknown }, expire: number }, okMsg: string) => {
|
||||
login(res.token, { ...res.user }, res.expire)
|
||||
const role = (res.user.role || '').toLowerCase()
|
||||
const staff = role === 'admin' || role === 'editor' || role.includes('admin') || role.includes('editor')
|
||||
setLoginType(staff ? 'staff' : 'guest')
|
||||
toast.success(okMsg)
|
||||
form.password = ''
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack()
|
||||
}
|
||||
else {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交:按 mode 分流普通登录 / 合并账号。
|
||||
*/
|
||||
const onSubmit = async (payload: { username: string, password: string }) => {
|
||||
form.username = payload.username
|
||||
form.password = payload.password
|
||||
if (!form.username || !form.password) {
|
||||
toast.show('请输入账号密码')
|
||||
return
|
||||
}
|
||||
if (isMerge.value && !isAuthenticated()) {
|
||||
toast.error('请先完成微信静默登录后再合并')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isMerge.value) {
|
||||
const res = await mergeAccountApi(form.username, form.password)
|
||||
finishAuth(res, '合并成功')
|
||||
}
|
||||
else {
|
||||
const res = await loginApi(form.username, form.password)
|
||||
finishAuth(res, '登录成功')
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : (isMerge.value ? '合并失败' : '登录失败'))
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<ContentPageHeader
|
||||
:eyebrow="headerEyebrow"
|
||||
:title="headerTitle"
|
||||
:desc="headerDesc"
|
||||
/>
|
||||
<view class="login-card art-card animate-reveal">
|
||||
<StaffLoginForm
|
||||
v-model:username="form.username"
|
||||
v-model:password="form.password"
|
||||
:loading="submitting"
|
||||
:submit-label="isMerge ? '确认合并' : '登录'"
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
<view class="hint text-art-muted">{{ hintText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad {
|
||||
padding: 32rpx 32rpx 80rpx;
|
||||
}
|
||||
.login-card {
|
||||
padding: 40rpx 32rpx;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 28rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
396
blog-wot-uniapp/src/subPackages/content/pages/services/index.vue
Normal file
396
blog-wot-uniapp/src/subPackages/content/pages/services/index.vue
Normal file
@@ -0,0 +1,396 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 合作页:hero、能力卡、创作流程、滚动客户原声、CTA+表单、伙伴。
|
||||
* 不做 PC 鼠标 3D tilt;触控用 art-card-press + CSS 滚动。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import { getTestimonials, getPartners, submitInquiry } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
interface Testimonial {
|
||||
id?: number
|
||||
name?: string
|
||||
role?: string
|
||||
content?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
interface Partner {
|
||||
id?: number
|
||||
name?: string
|
||||
logo?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const marqueePaused = ref(false)
|
||||
const processOpen = ref(0)
|
||||
const testimonials = ref<Testimonial[]>([])
|
||||
const partners = ref<Partner[]>([])
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
const capabilities = [
|
||||
{ icon: 'application', title: '前端架构设计', desc: '为复杂的大型应用提供可扩展的架构方案。', tone: 'blue' },
|
||||
{ icon: 'code-block', title: '创意交互开发', desc: '用动画与细节打造令人过目难忘的体验。', tone: 'gold' },
|
||||
{ icon: 'mobile', title: '跨平台应用', desc: '使用 UniApp 等技术交付高质量移动端。', tone: 'purple' },
|
||||
]
|
||||
|
||||
const processSteps = [
|
||||
{ title: '灵感 & 探索', desc: '深入理解业务需求,进行竞品分析,寻找视觉灵感,确定设计方向。' },
|
||||
{ title: '架构 & 设计', desc: '设计高保真原型,规划技术架构,确定数据流向,将抽象想法具象化。' },
|
||||
{ title: '编码 & 雕琢', desc: '编写干净可维护的代码,加入微交互与动效,让页面活起来。' },
|
||||
{ title: '测试 & 交付', desc: '多端测试、性能优化与上线配置,确保交付物稳定可靠。' },
|
||||
]
|
||||
|
||||
/** 双行弹幕:复制一份实现无缝滚动 */
|
||||
const marqueeList = computed(() => {
|
||||
const list = testimonials.value
|
||||
if (!list.length) return []
|
||||
return [...list, ...list]
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [t, p] = await Promise.all([
|
||||
getTestimonials().catch(() => []),
|
||||
getPartners().catch(() => []),
|
||||
])
|
||||
testimonials.value = (t || []) as Testimonial[]
|
||||
partners.value = (p || []) as Partner[]
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
const scrollToForm = () => {
|
||||
uni.pageScrollTo({ selector: '#inquiry-form', duration: 300 })
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!form.name || !form.email || !form.message) {
|
||||
toast.show('请填写姓名、邮箱与留言')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await submitInquiry({ ...form })
|
||||
toast.success('提交成功')
|
||||
form.name = ''
|
||||
form.email = ''
|
||||
form.company = ''
|
||||
form.message = ''
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '提交失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="合作" show-back />
|
||||
<view class="page-pad">
|
||||
<ContentPageHeader
|
||||
eyebrow="Services"
|
||||
title="共创数字未来"
|
||||
desc="用代码构建骨架,用设计触动灵魂。"
|
||||
/>
|
||||
|
||||
<view class="cap-grid">
|
||||
<view
|
||||
v-for="(c, i) in capabilities"
|
||||
:key="i"
|
||||
class="art-card art-card-press cap-card"
|
||||
:class="`cap-card--${c.tone}`"
|
||||
>
|
||||
<view class="cap-icon">
|
||||
<wd-icon :name="c.icon" size="28px" color="rgb(212,179,131)" />
|
||||
</view>
|
||||
<text class="cap-title">{{ c.title }}</text>
|
||||
<text class="cap-desc text-art-muted">{{ c.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="block">
|
||||
<view class="block-title">创作流程</view>
|
||||
<view class="process art-card">
|
||||
<view
|
||||
v-for="(step, i) in processSteps"
|
||||
:key="i"
|
||||
class="process-item"
|
||||
:class="{ 'process-item--open': processOpen === i }"
|
||||
@click="processOpen = processOpen === i ? -1 : i"
|
||||
>
|
||||
<view class="process-head">
|
||||
<text class="process-no font-mono-label text-art-accent">{{ String(i + 1).padStart(2, '0') }}</text>
|
||||
<text class="process-title">{{ step.title }}</text>
|
||||
<wd-icon
|
||||
:name="processOpen === i ? 'arrow-up' : 'arrow-down'"
|
||||
size="14px"
|
||||
color="rgb(136,136,136)"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="processOpen === i" class="step-desc text-art-muted">{{ step.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="marqueeList.length" class="block marquee-block">
|
||||
<view class="block-title">客户原声</view>
|
||||
<view
|
||||
class="marquee-viewport"
|
||||
@touchstart="marqueePaused = true"
|
||||
@touchend="marqueePaused = false"
|
||||
>
|
||||
<view class="marquee-row" :class="{ 'is-paused': marqueePaused }">
|
||||
<view
|
||||
v-for="(item, idx) in marqueeList"
|
||||
:key="'a-' + idx"
|
||||
class="danmaku art-card"
|
||||
>
|
||||
<image
|
||||
v-if="item.avatar"
|
||||
class="avatar"
|
||||
:src="resolveMediaUrl(String(item.avatar))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="avatar avatar--empty" />
|
||||
<text v-if="item.name" class="who text-art-accent">{{ item.name }}</text>
|
||||
<text class="quote">"{{ item.content }}"</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="marquee-row marquee-row--rev" :class="{ 'is-paused': marqueePaused }">
|
||||
<view
|
||||
v-for="(item, idx) in marqueeList"
|
||||
:key="'b-' + idx"
|
||||
class="danmaku art-card"
|
||||
>
|
||||
<image
|
||||
v-if="item.avatar"
|
||||
class="avatar"
|
||||
:src="resolveMediaUrl(String(item.avatar))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="avatar avatar--empty" />
|
||||
<text v-if="item.name" class="who text-art-accent">{{ item.name }}</text>
|
||||
<text class="quote">"{{ item.content }}"</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cta art-card">
|
||||
<view class="cta-glow" />
|
||||
<text class="cta-title">准备好开始了吗?</text>
|
||||
<text class="cta-desc text-art-muted">无论是一个疯狂的想法,还是一个具体的业务需求,我都乐意倾听。</text>
|
||||
<wd-button
|
||||
type="primary"
|
||||
custom-style="background:#d4b383;border-color:#d4b383;color:#171717;"
|
||||
@click="scrollToForm"
|
||||
>
|
||||
发起合作咨询
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view id="inquiry-form" class="block">
|
||||
<view class="block-title">提交咨询</view>
|
||||
<view class="art-card form-card">
|
||||
<wd-input v-model="form.name" label="姓名" placeholder="您的姓名" clearable />
|
||||
<wd-input v-model="form.email" label="邮箱" placeholder="联系邮箱" clearable />
|
||||
<wd-input v-model="form.company" label="公司" placeholder="可选" clearable />
|
||||
<wd-textarea v-model="form.message" label="留言" placeholder="项目需求或合作意向" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;color:#171717;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
提交
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="partners.length" class="block">
|
||||
<text class="trusted font-mono-label text-art-muted">Trusted By</text>
|
||||
<view class="partner-grid">
|
||||
<view v-for="(p, idx) in partners" :key="p.id || idx" class="art-card partner">
|
||||
<image
|
||||
v-if="p.logo"
|
||||
class="partner-logo"
|
||||
:src="resolveMediaUrl(String(p.logo))"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="partner-name">{{ p.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 100rpx; }
|
||||
.cap-grid { display: flex; flex-direction: column; gap: 20rpx; margin-bottom: 48rpx; }
|
||||
.cap-card { padding: 36rpx 32rpx; }
|
||||
.cap-icon {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(212, 179, 131, 0.12);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.cap-title {
|
||||
display: block;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.cap-desc { font-size: 26rpx; line-height: 1.6; }
|
||||
.block { margin-bottom: 56rpx; }
|
||||
.block-title {
|
||||
font-size: 36rpx;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 24rpx;
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-style: italic;
|
||||
}
|
||||
.process { overflow: hidden; }
|
||||
.process-item {
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 1px solid var(--art-card-border);
|
||||
}
|
||||
.process-item:last-child { border-bottom: none; }
|
||||
.process-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.process-no { flex-shrink: 0; }
|
||||
.process-title {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: rgb(var(--art-text));
|
||||
font-weight: 600;
|
||||
}
|
||||
.step-desc {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.65;
|
||||
padding: 16rpx 0 4rpx 56rpx;
|
||||
}
|
||||
.marquee-block { overflow: hidden; }
|
||||
.marquee-viewport { display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.marquee-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
width: max-content;
|
||||
animation: marquee 36s linear infinite;
|
||||
}
|
||||
.marquee-row--rev { animation-name: marquee-rev; animation-duration: 42s; }
|
||||
.marquee-row.is-paused { animation-play-state: paused; }
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
@keyframes marquee-rev {
|
||||
from { transform: translateX(-50%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.danmaku {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
max-width: 520rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar--empty { background: rgba(212, 179, 131, 0.25); }
|
||||
.who { font-size: 22rpx; flex-shrink: 0; }
|
||||
.quote {
|
||||
font-size: 24rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 360rpx;
|
||||
}
|
||||
.cta {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 48rpx 36rpx;
|
||||
margin-bottom: 48rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.cta-glow {
|
||||
position: absolute;
|
||||
top: -40rpx;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(212, 179, 131, 0.22);
|
||||
filter: blur(40px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cta-title {
|
||||
position: relative;
|
||||
font-size: 40rpx;
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-style: italic;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
.cta-desc { position: relative; font-size: 26rpx; line-height: 1.6; max-width: 560rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.trusted {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-bottom: 24rpx;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
.partner-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
|
||||
.partner {
|
||||
padding: 28rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.partner-logo { width: 96rpx; height: 64rpx; }
|
||||
.partner-name { font-size: 24rpx; color: rgb(var(--art-text)); text-align: center; }
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user