1. 小程序端

2. 视频优化2
This commit is contained in:
李琦
2026-07-30 14:14:28 +08:00
parent a9eb20f0e1
commit 4e245f789b
115 changed files with 24053 additions and 1 deletions

1
.idea/vcs.xml generated
View File

@@ -2,6 +2,5 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/blog-wot-uniapp" vcs="Git" />
</component> </component>
</project> </project>

View File

@@ -0,0 +1,9 @@
---
description:
alwaysApply: true
---
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
2. 注意不要生成太多的空行上一部分代码和下一部分代码中间的空行不要大于2行
3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面

View 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 组件。

View 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)

View File

@@ -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/*` 与相应命令实现。

View File

@@ -0,0 +1,2 @@
VITE_API_BASE=http://127.0.0.1:8081/api
VITE_UPLOAD_BASE=http://127.0.0.1:8081

View 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
View 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
View 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
View 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
View 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
View 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']
}
}

View 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

View 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

View File

@@ -0,0 +1,3 @@
import uniHelper from '@uni-helper/eslint-config'
export default uniHelper()

View 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>

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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>

View 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 }
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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,
}
}

View 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}` : ''
}

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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
View 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
}

View 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,
}
}

View 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"
}

View 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"
}
}

View 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>

View 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>

View 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>

View 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`,
}
}
},
}
}

View 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

View 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

View 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

View 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');
}

View 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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }
}

View 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 ''
}

View File

@@ -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: '' },
]

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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![](${url})\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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/**
* 单段代码 → 带 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()
}

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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