diff --git a/.cursor/rules/Code-Standards.mdc b/.cursor/rules/Code-Standards.mdc new file mode 100644 index 0000000..66a5cb3 --- /dev/null +++ b/.cursor/rules/Code-Standards.mdc @@ -0,0 +1,9 @@ +--- +description: +alwaysApply: true +--- + +1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则 +2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行 +3. 有封装好的方法、组件需要复用,不要重复造轮子 +4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面 diff --git a/.cursor/rules/wot-ui.mdc b/.cursor/rules/wot-ui.mdc new file mode 100644 index 0000000..245bf75 --- /dev/null +++ b/.cursor/rules/wot-ui.mdc @@ -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 组件。 diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..cb5e7f2 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +VITE_API_BASE=http://127.0.0.1:8081/api +VITE_UPLOAD_BASE=http://127.0.0.1:8081 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..e82e1ef --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +VITE_API_BASE=https://blog.nailaoyun.cn/api +VITE_UPLOAD_BASE=https://blog.nailaoyun.cn diff --git a/design-system/年糕崽崽/MASTER.md b/design-system/年糕崽崽/MASTER.md new file mode 100644 index 0000000..3dde419 --- /dev/null +++ b/design-system/年糕崽崽/MASTER.md @@ -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 diff --git a/design-system/年糕崽崽/pages/mine.md b/design-system/年糕崽崽/pages/mine.md new file mode 100644 index 0000000..2ee5f68 --- /dev/null +++ b/design-system/年糕崽崽/pages/mine.md @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ee14c69 --- /dev/null +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..7c59d59 --- /dev/null +++ b/src/api/index.ts @@ -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 { + 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('/admin/login', { username, password }) + +export const wxLoginApi = (code: string) => + post('/auth/wx-login', { code }) + +/** 游客合并到管理员:需携带游客 JWT */ +export const mergeAccountApi = (username: string, password: string) => + post('/auth/merge-account', { username, password }, true) + +export const getCurrentUser = () => get('/admin/me', undefined, true) + +/** 更新当前登录用户资料 */ +export const updateMe = (data: Partial) => put('/admin/me', data, true) + +export const updatePassword = (oldPassword: string, newPassword: string) => + put('/admin/me/password', { oldPassword, newPassword }, true) + +// ---- Public ---- +export const getPublicSettings = () => get>('/settings') + +export const getPosts = (params: Record = {}) => + get | Post[]>('/posts', params) + +export const getPost = (id: string | number) => get(`/posts/${id}`) + +export const getRecommendedPosts = (id: string | number, limit = 3) => + get(`/posts/${id}/recommendations`, { limit }) + +export const getHotSearches = (limit = 10) => get<{ keyword: string, count: number }[]>('/search/hot', { limit }) + +export const getColumns = () => get('/columns') +export const getColumn = (id: string | number) => get(`/columns/${id}`) +export const getColumnPosts = (id: string | number) => get(`/columns/${id}/posts`) + +export const getWorks = () => get('/works') +export const getWork = (id: string) => get(`/works/${id}`) + +export const getSnippets = () => get('/snippets') +export const getSnippet = (id: string | number) => get(`/snippets/${id}`) + +export const getVideoCategories = () => get('/video-categories') +export const getVideoAlbums = (params: Record = {}) => + get('/video-albums', params) +export const getVideoAlbum = (id: string | number) => get(`/video-albums/${id}`) +export const getVideos = (params: Record = {}) => get('/videos', params) +export const getVideo = (id: string) => get(`/videos/${id}`) +export const getAlbumVideos = (id: string | number) => get(`/video-albums/${id}/videos`) + +export const getAbout = () => get('/about') +export const getTestimonials = () => get('/testimonials') +export const getPartners = () => get('/partners') +export const getEmailSuffixes = () => get('/email-suffixes') +export const submitInquiry = (data: Record) => 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 = {}) => + get('/admin/dashboard/stats', params, true) +export const getRecentActivities = () => get('/admin/dashboard/activities', undefined, true) +export const getOperationLogs = (page = 1, pageSize = 10, filters: Record = {}) => + get>('/admin/operation-logs', { page, pageSize, ...filters }, true) +export const getAccessLogs = (page = 1, pageSize = 10, filters: Record = {}) => + get>('/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) => + patch(`/admin/posts/${id}/relations`, data, true) + +export const getPostHistory = (id: string | number) => + get(`/admin/posts/${id}/history`, undefined, true) + +/** 后台文章详情(含未发布),编辑表单专用;勿用公开 getPost */ +export const adminGetPost = (id: string | number) => + get(`/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 = {}) => + get>(`/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(`/admin/columns/${columnId}/posts`, undefined, true).catch(() => getColumnPosts(columnId)) + +// ---- Admin video albums ---- +export const getAlbumVideoIds = (id: string | number) => + get(`/admin/video-albums/${id}/video-ids`, undefined, true) + +// ---- Admin attachments ---- +export const uploadAttachment = (filePath: string, formData: Record = {}) => + uploadFile('/admin/attachments/upload', filePath, formData) + +// ---- Admin settings ---- +export const getAdminSettings = () => get('/admin/settings', undefined, true) +export const batchUpdateSettings = (values: Record) => + 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 = { + 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 = {}) { + return get(adminPath(resource), params, true) +} + +export function adminGet(resource: string, id: string | number) { + return get(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>(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>( + data: unknown, + page = 1, + pageSize = 20, +): PaginationResponse { + if (data && typeof data === 'object' && Array.isArray((data as PaginationResponse).list)) { + const p = data as PaginationResponse + return { + list: p.list, + total: Number(p.total ?? p.list.length), + page: Number(p.page ?? page), + pageSize: Number(p.pageSize ?? pageSize), + } + } + const list = normalizeAdminList(data) + return { list, total: list.length, page, pageSize } +} diff --git a/src/assets/fonts/jetbrains-mono-latin-400-normal.woff b/src/assets/fonts/jetbrains-mono-latin-400-normal.woff new file mode 100644 index 0000000..41fa3d6 Binary files /dev/null and b/src/assets/fonts/jetbrains-mono-latin-400-normal.woff differ diff --git a/src/assets/fonts/playfair-display-latin-400-italic.woff b/src/assets/fonts/playfair-display-latin-400-italic.woff new file mode 100644 index 0000000..c5f92cb Binary files /dev/null and b/src/assets/fonts/playfair-display-latin-400-italic.woff differ diff --git a/src/assets/fonts/playfair-display-latin-400-normal.woff b/src/assets/fonts/playfair-display-latin-400-normal.woff new file mode 100644 index 0000000..2620a9b Binary files /dev/null and b/src/assets/fonts/playfair-display-latin-400-normal.woff differ diff --git a/src/assets/fonts/playfair-display-latin-600-normal.woff b/src/assets/fonts/playfair-display-latin-600-normal.woff new file mode 100644 index 0000000..61aebd2 Binary files /dev/null and b/src/assets/fonts/playfair-display-latin-600-normal.woff differ diff --git a/src/assets/fonts/playfair-display-latin-700-normal.woff b/src/assets/fonts/playfair-display-latin-700-normal.woff new file mode 100644 index 0000000..44f1c67 Binary files /dev/null and b/src/assets/fonts/playfair-display-latin-700-normal.woff differ diff --git a/src/components/c-end/AmbientBackground.vue b/src/components/c-end/AmbientBackground.vue new file mode 100644 index 0000000..4e9eb7e --- /dev/null +++ b/src/components/c-end/AmbientBackground.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/src/components/c-end/ArtMenuGroup.vue b/src/components/c-end/ArtMenuGroup.vue new file mode 100644 index 0000000..5367cfd --- /dev/null +++ b/src/components/c-end/ArtMenuGroup.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/src/components/c-end/ArtMenuItem.vue b/src/components/c-end/ArtMenuItem.vue new file mode 100644 index 0000000..13e9922 --- /dev/null +++ b/src/components/c-end/ArtMenuItem.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/src/components/c-end/ArtSearchBar.vue b/src/components/c-end/ArtSearchBar.vue new file mode 100644 index 0000000..2c4378c --- /dev/null +++ b/src/components/c-end/ArtSearchBar.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/components/c-end/PostCard.vue b/src/components/c-end/PostCard.vue new file mode 100644 index 0000000..1f11de1 --- /dev/null +++ b/src/components/c-end/PostCard.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/components/c-end/ProfileHero.vue b/src/components/c-end/ProfileHero.vue new file mode 100644 index 0000000..9180f64 --- /dev/null +++ b/src/components/c-end/ProfileHero.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/components/layout/AppFloatingTabbar.vue b/src/components/layout/AppFloatingTabbar.vue new file mode 100644 index 0000000..7a85df9 --- /dev/null +++ b/src/components/layout/AppFloatingTabbar.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/components/layout/AppNavbar.vue b/src/components/layout/AppNavbar.vue new file mode 100644 index 0000000..a73b332 --- /dev/null +++ b/src/components/layout/AppNavbar.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/src/components/layout/AppPageShell.vue b/src/components/layout/AppPageShell.vue new file mode 100644 index 0000000..200226d --- /dev/null +++ b/src/components/layout/AppPageShell.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/composables/useAppMode.ts b/src/composables/useAppMode.ts new file mode 100644 index 0000000..4fd203c --- /dev/null +++ b/src/composables/useAppMode.ts @@ -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((uni.getStorageSync(MODE_KEY) as AppMode) || 'user') +const loginType = ref((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, + } +} diff --git a/src/composables/useAppTabbar.ts b/src/composables/useAppTabbar.ts new file mode 100644 index 0000000..c8dd524 --- /dev/null +++ b/src/composables/useAppTabbar.ts @@ -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}` : '' +} diff --git a/src/composables/useAuth.ts b/src/composables/useAuth.ts new file mode 100644 index 0000000..fcc5f66 --- /dev/null +++ b/src/composables/useAuth.ts @@ -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 | 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, + } +} diff --git a/src/composables/usePublicSettings.ts b/src/composables/usePublicSettings.ts new file mode 100644 index 0000000..af2689c --- /dev/null +++ b/src/composables/usePublicSettings.ts @@ -0,0 +1,53 @@ +import { ref } from 'vue' +import { get } from '@/utils/request' +import { PUBLIC_MENUS } from '@/config/menus' + +export type PublicSettings = Record + +const cache = ref({}) +const loaded = ref(false) + +export function usePublicSettings() { + const load = async (force = false) => { + if (loaded.value && !force) return cache.value + try { + const data = await get('/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, + } +} diff --git a/src/composables/useSearchHistory.ts b/src/composables/useSearchHistory.ts new file mode 100644 index 0000000..4198097 --- /dev/null +++ b/src/composables/useSearchHistory.ts @@ -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, + } +} diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts new file mode 100644 index 0000000..bfc20cc --- /dev/null +++ b/src/composables/useTheme.ts @@ -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((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, + } +} diff --git a/src/config/menus.ts b/src/config/menus.ts new file mode 100644 index 0000000..995e8b2 --- /dev/null +++ b/src/config/menus.ts @@ -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') +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..c454ab5 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE: string + readonly VITE_UPLOAD_BASE: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/src/pages/explore/index.vue b/src/pages/explore/index.vue new file mode 100644 index 0000000..323ee4c --- /dev/null +++ b/src/pages/explore/index.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/src/pages/home/index.vue b/src/pages/home/index.vue new file mode 100644 index 0000000..47c7d3a --- /dev/null +++ b/src/pages/home/index.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/src/pages/mine/index.vue b/src/pages/mine/index.vue new file mode 100644 index 0000000..195a280 --- /dev/null +++ b/src/pages/mine/index.vue @@ -0,0 +1,263 @@ + + + diff --git a/src/resolvers/wot-ui-resolver.ts b/src/resolvers/wot-ui-resolver.ts new file mode 100644 index 0000000..085421e --- /dev/null +++ b/src/resolvers/wot-ui-resolver.ts @@ -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`, + } + } + }, + } +} diff --git a/src/static/default-avatar.svg b/src/static/default-avatar.svg new file mode 100644 index 0000000..21bb93e --- /dev/null +++ b/src/static/default-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/styles/art-fonts.scss b/src/styles/art-fonts.scss new file mode 100644 index 0000000..8576fd7 --- /dev/null +++ b/src/styles/art-fonts.scss @@ -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'); +} diff --git a/src/styles/art-tokens.scss b/src/styles/art-tokens.scss new file mode 100644 index 0000000..f1a84d7 --- /dev/null +++ b/src/styles/art-tokens.scss @@ -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); +} diff --git a/src/subPackages/admin/components/AdminBarChart.vue b/src/subPackages/admin/components/AdminBarChart.vue new file mode 100644 index 0000000..15d2a6a --- /dev/null +++ b/src/subPackages/admin/components/AdminBarChart.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/subPackages/admin/components/AdminDetailDrawer.vue b/src/subPackages/admin/components/AdminDetailDrawer.vue new file mode 100644 index 0000000..97b5954 --- /dev/null +++ b/src/subPackages/admin/components/AdminDetailDrawer.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/subPackages/admin/components/AdminFilterBar.vue b/src/subPackages/admin/components/AdminFilterBar.vue new file mode 100644 index 0000000..67b5cd0 --- /dev/null +++ b/src/subPackages/admin/components/AdminFilterBar.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/subPackages/admin/components/AdminFormField.vue b/src/subPackages/admin/components/AdminFormField.vue new file mode 100644 index 0000000..b11e226 --- /dev/null +++ b/src/subPackages/admin/components/AdminFormField.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/src/subPackages/admin/components/AdminLineChart.vue b/src/subPackages/admin/components/AdminLineChart.vue new file mode 100644 index 0000000..d764400 --- /dev/null +++ b/src/subPackages/admin/components/AdminLineChart.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/src/subPackages/admin/components/AdminMediaPicker.vue b/src/subPackages/admin/components/AdminMediaPicker.vue new file mode 100644 index 0000000..db8ee12 --- /dev/null +++ b/src/subPackages/admin/components/AdminMediaPicker.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/subPackages/admin/components/AdminStatusBadge.vue b/src/subPackages/admin/components/AdminStatusBadge.vue new file mode 100644 index 0000000..76959ca --- /dev/null +++ b/src/subPackages/admin/components/AdminStatusBadge.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/subPackages/admin/composables/useAdminGuard.ts b/src/subPackages/admin/composables/useAdminGuard.ts new file mode 100644 index 0000000..3cf06e8 --- /dev/null +++ b/src/subPackages/admin/composables/useAdminGuard.ts @@ -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 } +} diff --git a/src/subPackages/admin/config/resourceSchemas.ts b/src/subPackages/admin/config/resourceSchemas.ts new file mode 100644 index 0000000..50a9b2f --- /dev/null +++ b/src/subPackages/admin/config/resourceSchemas.ts @@ -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 + 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 = { + 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, keys?: string[]): string { + if (!keys?.length) return '' + for (const k of keys) { + const v = item[k] + if (v != null && v !== '') return String(v) + } + return '' +} diff --git a/src/subPackages/admin/config/settingsSchema.ts b/src/subPackages/admin/config/settingsSchema.ts new file mode 100644 index 0000000..22e8f5b --- /dev/null +++ b/src/subPackages/admin/config/settingsSchema.ts @@ -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: '' }, +] diff --git a/src/subPackages/admin/pages/about/index.vue b/src/subPackages/admin/pages/about/index.vue new file mode 100644 index 0000000..8396328 --- /dev/null +++ b/src/subPackages/admin/pages/about/index.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/src/subPackages/admin/pages/analytics/index.vue b/src/subPackages/admin/pages/analytics/index.vue new file mode 100644 index 0000000..8b776e9 --- /dev/null +++ b/src/subPackages/admin/pages/analytics/index.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/subPackages/admin/pages/attachments/index.vue b/src/subPackages/admin/pages/attachments/index.vue new file mode 100644 index 0000000..5a0ad1c --- /dev/null +++ b/src/subPackages/admin/pages/attachments/index.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/src/subPackages/admin/pages/columns/form.vue b/src/subPackages/admin/pages/columns/form.vue new file mode 100644 index 0000000..172f68a --- /dev/null +++ b/src/subPackages/admin/pages/columns/form.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/src/subPackages/admin/pages/dashboard/index.vue b/src/subPackages/admin/pages/dashboard/index.vue new file mode 100644 index 0000000..b0224ee --- /dev/null +++ b/src/subPackages/admin/pages/dashboard/index.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/src/subPackages/admin/pages/explore/index.vue b/src/subPackages/admin/pages/explore/index.vue new file mode 100644 index 0000000..4b46201 --- /dev/null +++ b/src/subPackages/admin/pages/explore/index.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/subPackages/admin/pages/inquiries/list.vue b/src/subPackages/admin/pages/inquiries/list.vue new file mode 100644 index 0000000..99cf8d4 --- /dev/null +++ b/src/subPackages/admin/pages/inquiries/list.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/src/subPackages/admin/pages/oss-configs/index.vue b/src/subPackages/admin/pages/oss-configs/index.vue new file mode 100644 index 0000000..3ce355d --- /dev/null +++ b/src/subPackages/admin/pages/oss-configs/index.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/src/subPackages/admin/pages/posts/form.vue b/src/subPackages/admin/pages/posts/form.vue new file mode 100644 index 0000000..72ed2e3 --- /dev/null +++ b/src/subPackages/admin/pages/posts/form.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/src/subPackages/admin/pages/posts/list.vue b/src/subPackages/admin/pages/posts/list.vue new file mode 100644 index 0000000..04bf50a --- /dev/null +++ b/src/subPackages/admin/pages/posts/list.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/subPackages/admin/pages/profile/index.vue b/src/subPackages/admin/pages/profile/index.vue new file mode 100644 index 0000000..6ac998f --- /dev/null +++ b/src/subPackages/admin/pages/profile/index.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/src/subPackages/admin/pages/resource/form.vue b/src/subPackages/admin/pages/resource/form.vue new file mode 100644 index 0000000..637a130 --- /dev/null +++ b/src/subPackages/admin/pages/resource/form.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/src/subPackages/admin/pages/resource/list.vue b/src/subPackages/admin/pages/resource/list.vue new file mode 100644 index 0000000..077aa7c --- /dev/null +++ b/src/subPackages/admin/pages/resource/list.vue @@ -0,0 +1,404 @@ + + + + + diff --git a/src/subPackages/admin/pages/settings/index.vue b/src/subPackages/admin/pages/settings/index.vue new file mode 100644 index 0000000..9c5c581 --- /dev/null +++ b/src/subPackages/admin/pages/settings/index.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/subPackages/admin/pages/snippets/form.vue b/src/subPackages/admin/pages/snippets/form.vue new file mode 100644 index 0000000..fde1638 --- /dev/null +++ b/src/subPackages/admin/pages/snippets/form.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/subPackages/admin/pages/users/form.vue b/src/subPackages/admin/pages/users/form.vue new file mode 100644 index 0000000..ee4204d --- /dev/null +++ b/src/subPackages/admin/pages/users/form.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/src/subPackages/admin/pages/users/list.vue b/src/subPackages/admin/pages/users/list.vue new file mode 100644 index 0000000..73d8730 --- /dev/null +++ b/src/subPackages/admin/pages/users/list.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/src/subPackages/admin/pages/video-albums/form.vue b/src/subPackages/admin/pages/video-albums/form.vue new file mode 100644 index 0000000..9f88c2a --- /dev/null +++ b/src/subPackages/admin/pages/video-albums/form.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/subPackages/admin/pages/videos/form.vue b/src/subPackages/admin/pages/videos/form.vue new file mode 100644 index 0000000..f233a0d --- /dev/null +++ b/src/subPackages/admin/pages/videos/form.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/src/subPackages/admin/pages/videos/list.vue b/src/subPackages/admin/pages/videos/list.vue new file mode 100644 index 0000000..04ff7fd --- /dev/null +++ b/src/subPackages/admin/pages/videos/list.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/subPackages/admin/pages/works/form.vue b/src/subPackages/admin/pages/works/form.vue new file mode 100644 index 0000000..c039bde --- /dev/null +++ b/src/subPackages/admin/pages/works/form.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/src/subPackages/admin/pages/works/list.vue b/src/subPackages/admin/pages/works/list.vue new file mode 100644 index 0000000..cee157e --- /dev/null +++ b/src/subPackages/admin/pages/works/list.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/subPackages/content/components/CodeBlock.vue b/src/subPackages/content/components/CodeBlock.vue new file mode 100644 index 0000000..6af6c8f --- /dev/null +++ b/src/subPackages/content/components/CodeBlock.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/subPackages/content/components/ContentPageHeader.vue b/src/subPackages/content/components/ContentPageHeader.vue new file mode 100644 index 0000000..6286217 --- /dev/null +++ b/src/subPackages/content/components/ContentPageHeader.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/src/subPackages/content/components/StaffLoginForm.vue b/src/subPackages/content/components/StaffLoginForm.vue new file mode 100644 index 0000000..b7ea03e --- /dev/null +++ b/src/subPackages/content/components/StaffLoginForm.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/subPackages/content/lib/codeHighlight.ts b/src/subPackages/content/lib/codeHighlight.ts new file mode 100644 index 0000000..02b4c7d --- /dev/null +++ b/src/subPackages/content/lib/codeHighlight.ts @@ -0,0 +1,24 @@ +/** + * 代码语言别名:对齐 PC client/src/utils/codeHighlight.ts + * 放在 content/lib,避免分包 utils 与主包 utils 路径冲突。 + */ +const LANGUAGE_ALIASES: Record = { + 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 +} diff --git a/src/subPackages/content/lib/markdownRenderer.ts b/src/subPackages/content/lib/markdownRenderer.ts new file mode 100644 index 0000000..565b2d9 --- /dev/null +++ b/src/subPackages/content/lib/markdownRenderer.ts @@ -0,0 +1,120 @@ +/** + * Markdown 渲染:marked + 树摇 highlight.js。 + * 放在 content/lib(勿用分包 utils,会与主包 utils 撞路径导致 ENOENT)。 + * 不做行号(对齐 PC 移动端)。 + */ +import { marked, type Tokens } from 'marked' +import type { HLJSApi, LanguageFn } from 'highlight.js' +import hljsCore from 'highlight.js/lib/core' +import javascript from 'highlight.js/lib/languages/javascript' +import typescript from 'highlight.js/lib/languages/typescript' +import python from 'highlight.js/lib/languages/python' +import go from 'highlight.js/lib/languages/go' +import java from 'highlight.js/lib/languages/java' +import css from 'highlight.js/lib/languages/css' +import xml from 'highlight.js/lib/languages/xml' +import json from 'highlight.js/lib/languages/json' +import bash from 'highlight.js/lib/languages/bash' +import sql from 'highlight.js/lib/languages/sql' +import yaml from 'highlight.js/lib/languages/yaml' +import markdown from 'highlight.js/lib/languages/markdown' +import { resolveHighlightLanguage } from './codeHighlight' +import { rewriteHtmlMediaUrls } from '@/utils/request' + +const LANGUAGE_REGISTRY: Array<[string, LanguageFn]> = [ + ['javascript', javascript], + ['typescript', typescript], + ['python', python], + ['go', go], + ['java', java], + ['css', css], + ['xml', xml], + ['html', xml], + ['json', json], + ['bash', bash], + ['sh', bash], + ['sql', sql], + ['yaml', yaml], + ['yml', yaml], + ['markdown', markdown], + ['md', markdown], +] + +let hljsInstance: HLJSApi | null = null +let markedReady = false + +function getHljs(): HLJSApi { + if (!hljsInstance) { + hljsInstance = hljsCore + for (const [name, register] of LANGUAGE_REGISTRY) { + hljsInstance.registerLanguage(name, register) + } + } + return hljsInstance +} + +/** 转义 HTML,避免未高亮代码注入 */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** + * 单段代码 → 带 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 ` +
+
+
+ +
+ ${label} +
+
${highlighted}
+
` +} + +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 { + if (!content) return '' + getHljs() + ensureMarked() + const html = marked.parse(content) as string + return rewriteHtmlMediaUrls(html) +} + +/** 预加载 highlight(进入详情/弹层前可调用) */ +export function preloadMarkdownRenderer(): void { + getHljs() +} diff --git a/src/subPackages/content/pages/about/index.vue b/src/subPackages/content/pages/about/index.vue new file mode 100644 index 0000000..450cda8 --- /dev/null +++ b/src/subPackages/content/pages/about/index.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/src/subPackages/content/pages/blog/detail.vue b/src/subPackages/content/pages/blog/detail.vue new file mode 100644 index 0000000..96fdf94 --- /dev/null +++ b/src/subPackages/content/pages/blog/detail.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/src/subPackages/content/pages/blog/index.vue b/src/subPackages/content/pages/blog/index.vue new file mode 100644 index 0000000..b3873c5 --- /dev/null +++ b/src/subPackages/content/pages/blog/index.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/src/subPackages/content/pages/blog/ppt.vue b/src/subPackages/content/pages/blog/ppt.vue new file mode 100644 index 0000000..e5dbad9 --- /dev/null +++ b/src/subPackages/content/pages/blog/ppt.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/subPackages/content/pages/columns/detail.vue b/src/subPackages/content/pages/columns/detail.vue new file mode 100644 index 0000000..c26e177 --- /dev/null +++ b/src/subPackages/content/pages/columns/detail.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/subPackages/content/pages/columns/index.vue b/src/subPackages/content/pages/columns/index.vue new file mode 100644 index 0000000..b24ded7 --- /dev/null +++ b/src/subPackages/content/pages/columns/index.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/src/subPackages/content/pages/login/index.vue b/src/subPackages/content/pages/login/index.vue new file mode 100644 index 0000000..232ef3f --- /dev/null +++ b/src/subPackages/content/pages/login/index.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/src/subPackages/content/pages/services/index.vue b/src/subPackages/content/pages/services/index.vue new file mode 100644 index 0000000..26a37d4 --- /dev/null +++ b/src/subPackages/content/pages/services/index.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/src/subPackages/content/pages/snippets/index.vue b/src/subPackages/content/pages/snippets/index.vue new file mode 100644 index 0000000..4e56e0a --- /dev/null +++ b/src/subPackages/content/pages/snippets/index.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/subPackages/content/pages/theme/index.vue b/src/subPackages/content/pages/theme/index.vue new file mode 100644 index 0000000..ea31e49 --- /dev/null +++ b/src/subPackages/content/pages/theme/index.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/src/subPackages/content/pages/videos/album.vue b/src/subPackages/content/pages/videos/album.vue new file mode 100644 index 0000000..53ffc46 --- /dev/null +++ b/src/subPackages/content/pages/videos/album.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/subPackages/content/pages/videos/detail.vue b/src/subPackages/content/pages/videos/detail.vue new file mode 100644 index 0000000..1780170 --- /dev/null +++ b/src/subPackages/content/pages/videos/detail.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/src/subPackages/content/pages/videos/index.vue b/src/subPackages/content/pages/videos/index.vue new file mode 100644 index 0000000..52fc10e --- /dev/null +++ b/src/subPackages/content/pages/videos/index.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/src/subPackages/content/pages/works/detail.vue b/src/subPackages/content/pages/works/detail.vue new file mode 100644 index 0000000..13b1208 --- /dev/null +++ b/src/subPackages/content/pages/works/detail.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/src/subPackages/content/pages/works/index.vue b/src/subPackages/content/pages/works/index.vue new file mode 100644 index 0000000..216d931 --- /dev/null +++ b/src/subPackages/content/pages/works/index.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/src/utils/content.ts b/src/utils/content.ts new file mode 100644 index 0000000..f4e13ea --- /dev/null +++ b/src/utils/content.ts @@ -0,0 +1,62 @@ +/** + * 去掉 HTML 标签,用于列表摘要展示。 + * 保留实体简单还原,避免简介全是标签噪音。 + */ +export function stripHtml(html?: string | null): string { + if (!html) return '' + return String(html) + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * 外链打开:H5 新开;小程序复制到剪贴板(无 web-view 页时的折中)。 + */ +export function openExternalUrl(url?: string) { + const u = String(url || '').trim() + if (!u) return + // #ifdef H5 + window.open(u, '_blank') + // #endif + // #ifndef H5 + uni.setClipboardData({ + data: u, + success: () => uni.showToast({ title: '链接已复制', icon: 'none' }), + }) + // #endif +} + +/** 解析作品 links(可能是 JSON 字符串) */ +export function parseWorkLinks(links: unknown): { live?: string, demo?: string, github?: string } { + if (!links) return {} + if (typeof links === 'string') { + try { + return JSON.parse(links) as { live?: string, demo?: string, github?: string } + } + catch { + return {} + } + } + if (typeof links === 'object') return links as { live?: string, demo?: string, github?: string } + return {} +} + +/** 展平技术栈分组为标签列表 */ +export function flattenTechStack(stack?: { category?: string, items?: string[], [k: string]: unknown }[]): string[] { + if (!Array.isArray(stack)) return [] + const out: string[] = [] + stack.forEach((g) => { + const items = Array.isArray(g.items) ? g.items : [] + items.forEach((i) => { + if (i) out.push(String(i)) + }) + // 兼容 { category, item } 扁平行 + if (!items.length && g.item) out.push(String(g.item)) + }) + return out +} diff --git a/src/utils/markdownRenderer.ts b/src/utils/markdownRenderer.ts new file mode 100644 index 0000000..35e32ee --- /dev/null +++ b/src/utils/markdownRenderer.ts @@ -0,0 +1,103 @@ +import { marked } 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 rust from 'highlight.js/lib/languages/rust' +import cpp from 'highlight.js/lib/languages/cpp' +import c from 'highlight.js/lib/languages/c' +import php from 'highlight.js/lib/languages/php' +import shell from 'highlight.js/lib/languages/shell' + +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], + ['rust', rust], + ['cpp', cpp], + ['c', c], + ['php', php], + ['shell', shell], +] + +let hljsInstance: HLJSApi | null = null +let configured = false + +function getHljs(): HLJSApi { + if (!hljsInstance) { + hljsInstance = hljsCore + for (const [name, register] of LANGUAGE_REGISTRY) { + hljsInstance.registerLanguage(name, register) + } + } + return hljsInstance +} + +function ensureMarked() { + if (configured) return + const hljs = getHljs() + marked.setOptions({ + gfm: true, + breaks: false, + }) + const renderer = new marked.Renderer() + renderer.code = ({ text, lang }: { text: string, lang?: string }) => { + const language = (lang || '').split(/\s+/)[0] + let highlighted = text + if (language && hljs.getLanguage(language)) { + try { + highlighted = hljs.highlight(text, { language, ignoreIllegals: true }).value + } + catch { + highlighted = hljs.highlightAuto(text).value + } + } + else { + highlighted = hljs.highlightAuto(text).value + } + return `
${highlighted}
\n` + } + renderer.heading = ({ text, depth }: { text: string, depth: number }) => { + const slug = `heading-${String(text).toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-')}` + return `${text}\n` + } + renderer.image = ({ href, text }: { href: string, text: string }) => { + return `${text || ''}` + } + marked.use({ renderer }) + configured = true +} + +export async function renderMarkdown(content: string): Promise { + if (!content) return '' + ensureMarked() + return marked.parse(content) as string +} + +export function preloadMarkdownRenderer(): void { + getHljs() + ensureMarked() +} diff --git a/src/utils/request.ts b/src/utils/request.ts new file mode 100644 index 0000000..b2188bf --- /dev/null +++ b/src/utils/request.ts @@ -0,0 +1,184 @@ +import { useAuth } from '@/composables/useAuth' + +export interface ApiResponse { + code: number + message: string + result: T +} + +const API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8081/api' +export const UPLOAD_BASE = import.meta.env.VITE_UPLOAD_BASE || 'http://127.0.0.1:8081' + +/** + * 媒体地址:已是完整链接 / data: 则原样返回;否则拼上传域名(后端 /uploads 等)。 + */ +export function getImageUrl(url?: string | null): string { + if (!url) return '' + const raw = String(url).trim() + if (!raw) return '' + if (/^https?:\/\//i.test(raw) || raw.startsWith('data:') || raw.startsWith('//')) return raw + if (raw.startsWith('/')) return `${UPLOAD_BASE}${raw}` + return `${UPLOAD_BASE}/${raw}` +} + +/** 兼容旧名,与 getImageUrl 同逻辑 */ +export const resolveMediaUrl = getImageUrl + +/** + * 重写 HTML 中相对路径的 src/href(如 /uploads/...),供 mp-html / rich-text 使用。 + * 已是 http(s) / data: / 协议相对 // 的不改。 + */ +export function rewriteHtmlMediaUrls(html?: string | null): string { + if (!html) return '' + return String(html).replace( + /\b(src|href)\s*=\s*(["'])(\/[^"'>\s]*)\2/gi, + (_m, attr: string, quote: string, path: string) => { + // 协议相对 //cdn... 不要当成本地路径 + if (path.startsWith('//')) return `${attr}=${quote}${path}${quote}` + return `${attr}=${quote}${getImageUrl(path)}${quote}` + }, + ) +} + +function joinUrl(path: string): string { + if (/^https?:\/\//i.test(path)) return path + const base = API_BASE.replace(/\/$/, '') + const p = path.startsWith('/') ? path : `/${path}` + if (p.startsWith('/api/')) return `${UPLOAD_BASE}${p}` + return `${base}${p}` +} + +/** + * GET 查询参数清洗:去掉 undefined/null/'',避免 uni.request 把 undefined 序列化成字面量 "undefined"。 + */ +function omitEmptyQueryParams(data?: Record) { + if (!data) return undefined + const entries = Object.entries(data).filter(([, v]) => v !== undefined && v !== null && v !== '') + if (!entries.length) return undefined + return Object.fromEntries(entries) +} + +type RequestOpts = { + method?: UniApp.RequestOptions['method'] + data?: UniApp.RequestOptions['data'] + header?: Record + auth?: boolean + skipSessionExpired?: boolean +} + +export async function request(path: string, options: RequestOpts = {}): Promise { + const { auth = false, skipSessionExpired = false, header = {}, method = 'GET', data } = options + const headers: Record = { + 'Content-Type': 'application/json', + ...header, + } + + if (auth) { + const { getToken } = useAuth() + const token = getToken() + if (token) headers.Authorization = `Bearer ${token}` + } + + /** GET 走清洗后的 query;其它方法保持原 body */ + const payload = method === 'GET' + ? omitEmptyQueryParams(data as Record | undefined) + : data + + return new Promise((resolve, reject) => { + uni.request({ + url: joinUrl(path), + method, + data: payload, + header: headers, + success: (res) => { + const body = res.data as ApiResponse + if (!body || typeof body !== 'object' || !('code' in body)) { + reject(new Error('响应解析失败')) + return + } + if (body.code === 401 && !skipSessionExpired) { + const { handleSessionExpired } = useAuth() + handleSessionExpired() + reject(new Error(body.message || '登录已过期,请重新登录')) + return + } + if (body.code !== 200) { + reject(new Error(body.message || '请求失败')) + return + } + resolve(body.result) + }, + fail: (err) => reject(new Error(err.errMsg || '网络错误')), + }) + }) +} + +export function get(path: string, data?: Record, auth = false) { + return request(path, { method: 'GET', data, auth }) +} + +export function post(path: string, data?: unknown, auth = false) { + return request(path, { method: 'POST', data: data as UniApp.RequestOptions['data'], auth }) +} + +export function put(path: string, data?: unknown, auth = false) { + return request(path, { method: 'PUT', data: data as UniApp.RequestOptions['data'], auth }) +} + +export function del(path: string, auth = true) { + return request(path, { method: 'DELETE', auth }) +} + +/** PATCH 请求(文章发布状态等) */ +export function patch(path: string, data?: unknown, auth = false) { + return request(path, { + method: 'PATCH' as UniApp.RequestOptions['method'], + data: data as UniApp.RequestOptions['data'], + auth, + }) +} + +/** + * 直传文件到后台附件接口。 + * uni.uploadFile 不走 JSON Content-Type,需单独拼 Authorization。 + */ +export function uploadFile( + path: string, + filePath: string, + formData: Record = {}, + name = 'file', +): Promise { + const { getToken } = useAuth() + const token = getToken() + const header: Record = {} + if (token) header.Authorization = `Bearer ${token}` + return new Promise((resolve, reject) => { + uni.uploadFile({ + url: joinUrl(path), + filePath, + name, + formData, + header, + success: (res) => { + try { + const body = JSON.parse(res.data) as ApiResponse + if (body.code === 401) { + const { handleSessionExpired } = useAuth() + handleSessionExpired() + reject(new Error(body.message || '登录已过期,请重新登录')) + return + } + if (body.code !== 200) { + reject(new Error(body.message || '上传失败')) + return + } + resolve(body.result) + } + catch { + reject(new Error('上传响应解析失败')) + } + }, + fail: (err) => reject(new Error(err.errMsg || '上传失败')), + }) + }) +} diff --git a/src/utils/unsupported.ts b/src/utils/unsupported.ts new file mode 100644 index 0000000..f0ff65a --- /dev/null +++ b/src/utils/unsupported.ts @@ -0,0 +1,20 @@ +import { useDialog } from '@wot-ui/ui' + +/** 小程序/App 暂不支持的能力统一提示 */ +export function showUnsupported(featureName: string) { + try { + const dialog = useDialog() + dialog.alert({ + title: '暂时不支持', + msg: `「${featureName}」在当前端暂不可用,请前往网页端使用。`, + confirmButtonText: '知道了', + }) + } + catch { + uni.showModal({ + title: '暂时不支持', + content: `「${featureName}」在当前端暂不可用,请前往网页端使用。`, + showCancel: false, + }) + } +}