AI代码生成工具、个人中心
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
李琦
2026-08-13 19:04:28 +08:00
parent c45bcde94d
commit 318aeab092
84 changed files with 37648 additions and 56 deletions

1
.gitignore vendored
View File

@@ -61,3 +61,4 @@ skills-lock.json
.atomcode
datalog
.playwright-mcp
/apps/desktop/release/win-unpacked/

6
.npmrc
View File

@@ -1 +1,7 @@
registry=https://registry.npmmirror.com
# electron 二进制与 electron-builder 打包工具链走国内镜像(不配国内基本装不动)
# 注意:新版 @electron/get 只认环境变量,若 pnpm install 后 electron 二进制缺失,
# 手动补装PowerShell 里 $env:ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
# 然后在 apps/desktop 下执行 node ./node_modules/electron/install.js
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/

View File

@@ -0,0 +1,6 @@
# ????????????
# ?????pnpm build:desktop:dev?electron-vite build --mode development?? electron-vite dev
# MAIN_VITE_ ?? = ???????electron-vite ???
# /api ?????????????nl-admin-api????????????
MAIN_VITE_API_UPSTREAM=http://127.0.0.1:18006/api

View File

@@ -0,0 +1,6 @@
# ????????????
# ?????pnpm build:desktop?electron-vite build ?? production ???
# TODO(???)??? /api ??????????????????????
# ????????????????????????
MAIN_VITE_API_UPSTREAM=http://127.0.0.1:18006/api

View File

@@ -0,0 +1,76 @@
# electron-builder 打包配置Windows + macOS 双平台)
#
# 签名/公证完全由环境变量驱动,本文件不写死任何证书:
# - 配置了 CSC_LINK + CSC_KEY_PASSWORD或 mac 钥匙串里有 Developer ID 证书)→ 自动签名;
# 没配置则 electron-builder 自动跳过签名,只告警不报错(出未签名包)
# - APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID 三者齐全 → 自动公证;缺任一则跳过
# - 未签名 mac 包首次打开需右键「打开」,自动更新由 updater.ts 降级为手动下载引导
#
# 注意mac 包必须在 macOS 机器上构建(不支持从 Windows 交叉编译),命令同一条 pnpm build:desktop
# 必须与 src/main/config.ts 的 APP_ID 一致Windows 通知 AUMID 对齐)
appId: com.nladmin.desktop
productName: Nl Admin
directories:
output: release
buildResources: resources
# 只打 electron-vite 产物:运行时依赖已全部打进 out/asar 里无需 node_modules
files:
- out/**
extraResources:
# web-antd 生产 dist → resources/webapp:// 协议的静态根;不进 asar为后续渲染层热替换留路
- from: ../web-antd/dist
to: web
# 应用图标供运行时使用(托盘 / 通知 / 窗口图标)
- from: resources/icon.png
to: icon.png
asar: true
# dependencies 为空(依赖全部打进 out/),跳过原生依赖重建
npmRebuild: false
# 产物名不带空格,避免更新托管 URL 转义问题
artifactName: nl-admin-desktop-${version}-${os}-${arch}.${ext}
win:
# 提供 ≥512px 的 pngelectron-builder 自动转 .ico
icon: resources/icon.png
target:
- target: nsis
arch:
- x64
nsis:
# 非一键安装:允许用户选择安装目录
oneClick: false
allowToChangeInstallationDirectory: true
# 开始菜单快捷方式是 Windows 通知AUMID生效的前提必须保留
createDesktopShortcut: always
createStartMenuShortcut: true
shortcutName: Nl Admin
mac:
# 同一张 png 自动转 .icns
icon: resources/icon.png
category: public.app-category.business
# 公证的前置要求;配合基础 entitlements无摄像头/麦克风,本项目无音视频)
hardenedRuntime: true
gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
target:
# zip 是 mac 自动更新latest-mac.yml的必需格式dmg 用于人工分发安装
- target: dmg
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
dmg:
# dmg 容器本身不签名zip 内的 .app 已按环境变量策略签名),减少无证书时的干扰告警
sign: false
publish:
# generic 静态托管:目录里放 latest.yml / latest-mac.yml / 安装包 / blockmap 即可
# TODO(待确认):与 src/main/config.ts 的 UPDATE_URL 保持一致
provider: generic
url: https://updates.example.com/nl-desktop/

View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'electron-vite';
/**
* electron-vite 构建配置
*
* 为什么不配置 renderer
* 渲染层不由本工程构建——开发期直接加载 web-antd 的 vite dev serverhttp://localhost:14002
* 生产期通过 app:// 自定义协议加载 web-antd 的生产 distelectron-builder extraResources 拷入)。
*
* 为什么不用 externalizeDepsPlugin
* 故意把 electron-updater / electron-log / electron-store 等运行时依赖全部打进 out/ 产物,
* 使应用的 dependencies 为空electron-builder 无需收集 node_modules
* 从而规避 pnpm 软链目录结构在打包期的兼容问题asar 里只有 out/ 与 package.json
*/
export default defineConfig({
main: {
build: {
outDir: 'out/main',
},
},
preload: {
build: {
outDir: 'out/preload',
rollupOptions: {
output: {
// sandbox: true 的 preload 必须是 CommonJSElectron 硬性限制),
// 这里锁定输出为 index.cjs主进程按该文件名引用
format: 'cjs',
entryFileNames: '[name].cjs',
},
},
},
},
});

26913
apps/desktop/out/main/index.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
//#region \0rolldown/runtime.js
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
//#endregion
//#region src/preload/index.ts
var import_electron = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
var { spawnSync } = require("child_process");
var fs = require("fs");
var path = require("path");
var pathFile = path.join(__dirname, "path.txt");
function downloadElectron() {
console.log("Downloading Electron binary...");
if (spawnSync(process.execPath, [path.join(__dirname, "install.js")], { stdio: "inherit" }).status !== 0) throw new Error("Electron failed to install correctly. Please delete `node_modules/electron` and run \"npx install-electron --no\" manually.");
}
/**
* Fetches the path to the Electron executable to use in development mode.
* If the executable is missing, attempt to download it first.
*
* @returns the path to the Electron executable to run
*/
function getElectronPath() {
let executablePath;
if (fs.existsSync(pathFile)) executablePath = fs.readFileSync(pathFile, "utf-8");
if (process.env.ELECTRON_OVERRIDE_DIST_PATH) return path.join(process.env.ELECTRON_OVERRIDE_DIST_PATH, executablePath || "electron");
if (executablePath) {
const fullPath = path.join(__dirname, "dist", executablePath);
if (!fs.existsSync(fullPath)) downloadElectron();
return fullPath;
} else {
try {
downloadElectron();
} catch {
throw new Error("Electron failed to install correctly. Please delete `node_modules/electron` and run \"npx install-electron --no\" manually.");
}
executablePath = fs.readFileSync(pathFile, "utf-8");
return path.join(__dirname, "dist", executablePath);
}
}
module.exports = getElectronPath();
})))();
/**
* 用 canvas 绘制 Windows 任务栏角标覆盖图(红底白字数字),返回 dataURL
* 为什么在 preload 画:主进程没有 DOM canvasnativeImage 也不能绘制文字;
* preload 跑在渲染进程里有完整 DOM 能力,画完把 dataURL 交给主进程 setOverlayIcon
*/
function drawBadgeDataURL(count) {
const size = 32;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const text = count > 99 ? "99+" : String(count);
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.fillStyle = "#f5222d";
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.font = `bold ${text.length > 2 ? 13 : 18}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, size / 2, 17);
return canvas.toDataURL("image/png");
}
/** 订阅主进程推送事件的通用封装,返回取消订阅函数(组件卸载时记得调用防止泄漏) */
function subscribe(channel, callback) {
const listener = (_event, payload) => callback(payload);
import_electron.ipcRenderer.on(channel, listener);
return () => import_electron.ipcRenderer.removeListener(channel, listener);
}
var desktopAPI = {
/** 恒为 trueweb 侧用它判断是否运行在 Electron 壳内 */
isElectron: true,
/** 当前平台win32 / darwin / linux渲染层做平台差异 UI 时用) */
platform: process.platform,
/** 应用版本号package.json version用于「关于」展示 */
getVersion: () => import_electron.ipcRenderer.invoke("desktop:get-version"),
/**
* 发系统通知route 传入站内路由(如 /notice/list时点击通知会唤起窗口并跳转
* flash 默认 true窗口未聚焦时同时闪烁任务栏mac 表现为 Dock 弹跳)
*/
notify: (options) => import_electron.ipcRenderer.invoke("desktop:notify", options),
/** 设置未读角标数0 清除mac 用 Dock 原生数字Windows 用 canvas 画的覆盖图 */
setBadge: (count) => {
const dataUrl = count > 0 && process.platform === "win32" ? drawBadgeDataURL(count) : "";
return import_electron.ipcRenderer.invoke("desktop:set-badge", count, dataUrl);
},
/** 手动控制任务栏闪烁一般用不到notify 已内置) */
flashFrame: (flag = true) => import_electron.ipcRenderer.invoke("desktop:flash-frame", flag),
/** 读取开机自启状态 */
getAutoLaunch: () => import_electron.ipcRenderer.invoke("desktop:get-auto-launch"),
/** 设置开机自启(--hidden 静默启动进托盘) */
setAutoLaunch: (enabled) => import_electron.ipcRenderer.invoke("desktop:set-auto-launch", enabled),
/** 枚举系统打印机列表 */
getPrinters: () => import_electron.ipcRenderer.invoke("desktop:get-printers"),
/**
* 打印一段完整 HTML不传 deviceName 时读持久化的默认打印机;
* silent=true 静默打印false 弹系统打印对话框
*/
printHTML: (options) => import_electron.ipcRenderer.invoke("desktop:print-html", options),
/** 读桌面端本地设置(如默认打印机) */
getSetting: (key) => import_electron.ipcRenderer.invoke("desktop:get-setting", key),
/** 写桌面端本地设置 */
setSetting: (key, value) => import_electron.ipcRenderer.invoke("desktop:set-setting", key, value),
/** 手动检查更新(托盘菜单也有同款入口) */
checkUpdate: () => import_electron.ipcRenderer.invoke("desktop:check-update"),
/** 更新下载完成后立即重启安装 */
quitAndInstall: () => import_electron.ipcRenderer.invoke("desktop:quit-and-install"),
/** 订阅「点击通知要求跳转路由」事件web 侧收到后自行 router.push */
onNavigate: (callback) => subscribe("desktop:navigate", callback),
/** 订阅更新流程事件checking / available / progress / downloaded 等),用于展示进度 */
onUpdateEvent: (callback) => subscribe("desktop:update-event", callback)
};
import_electron.contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
//#endregion

29
apps/desktop/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@vben/desktop",
"version": "1.0.0",
"private": true,
"description": "Nl Admin 桌面端壳工程app:// 协议复用 web-antd 生产 dist主进程转发 /api 到后端",
"productName": "Nl Admin",
"author": "nl-admin",
"type": "module",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev --watch",
"build": "electron-vite build && electron-builder --config electron-builder.yml --publish never",
"build:dev": "electron-vite build --mode development && electron-builder --config electron-builder.yml --publish never -c.artifactName=nl-admin-desktop-${version}-${os}-${arch}-dev.${ext}",
"build:dir": "electron-vite build && electron-builder --dir --config electron-builder.yml",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "catalog:",
"@vben/web-antd": "workspace:*",
"electron": "catalog:",
"electron-builder": "catalog:",
"electron-log": "catalog:",
"electron-store": "catalog:",
"electron-updater": "catalog:",
"electron-vite": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,228 @@
x64:
firstOrDefaultFilePatterns:
- '!**/node_modules/**'
- '!resources{,/**/*}'
- '!release{,/**/*}'
- out/**
- package.json
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
- '!**/._*'
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
- '!.yarn{,/**/*}'
- '!.editorconfig'
- '!.yarnrc.yml'
nodeModuleFilePatterns:
- '**/*'
- out/**
nsis:
script: |-
!include "D:\myCode\nl-admin\nl-admin-view\node_modules\.pnpm\app-builder-lib@26.15.3_dmg_ed407063d9219dada699e6fa1d0a9e89\node_modules\app-builder-lib\templates\nsis\include\StdUtils.nsh"
!addincludedir "D:\myCode\nl-admin\nl-admin-view\node_modules\.pnpm\app-builder-lib@26.15.3_dmg_ed407063d9219dada699e6fa1d0a9e89\node_modules\app-builder-lib\templates\nsis\include"
!macro _isUpdated _a _b _t _f
${StdUtils.TestParameter} $R9 "updated"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isUpdated `"" isUpdated ""`
!macro _isForceRun _a _b _t _f
${StdUtils.TestParameter} $R9 "force-run"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isForceRun `"" isForceRun ""`
!macro _isKeepShortcuts _a _b _t _f
${StdUtils.TestParameter} $R9 "keep-shortcuts"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isKeepShortcuts `"" isKeepShortcuts ""`
!macro _isNoDesktopShortcut _a _b _t _f
${StdUtils.TestParameter} $R9 "no-desktop-shortcut"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isNoDesktopShortcut `"" isNoDesktopShortcut ""`
!macro _isDeleteAppData _a _b _t _f
${StdUtils.TestParameter} $R9 "delete-app-data"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isDeleteAppData `"" isDeleteAppData ""`
!macro _isForAllUsers _a _b _t _f
${StdUtils.TestParameter} $R9 "allusers"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isForAllUsers `"" isForAllUsers ""`
!macro _isForCurrentUser _a _b _t _f
${StdUtils.TestParameter} $R9 "currentuser"
StrCmp "$R9" "true" `${_t}` `${_f}`
!macroend
!define isForCurrentUser `"" isForCurrentUser ""`
!macro addLangs
!insertmacro MUI_LANGUAGE "English"
!insertmacro MUI_LANGUAGE "German"
!insertmacro MUI_LANGUAGE "French"
!insertmacro MUI_LANGUAGE "SpanishInternational"
!insertmacro MUI_LANGUAGE "SimpChinese"
!insertmacro MUI_LANGUAGE "TradChinese"
!insertmacro MUI_LANGUAGE "Japanese"
!insertmacro MUI_LANGUAGE "Korean"
!insertmacro MUI_LANGUAGE "Italian"
!insertmacro MUI_LANGUAGE "Dutch"
!insertmacro MUI_LANGUAGE "Danish"
!insertmacro MUI_LANGUAGE "Swedish"
!insertmacro MUI_LANGUAGE "Norwegian"
!insertmacro MUI_LANGUAGE "Finnish"
!insertmacro MUI_LANGUAGE "Russian"
!insertmacro MUI_LANGUAGE "Portuguese"
!insertmacro MUI_LANGUAGE "PortugueseBR"
!insertmacro MUI_LANGUAGE "Polish"
!insertmacro MUI_LANGUAGE "Ukrainian"
!insertmacro MUI_LANGUAGE "Czech"
!insertmacro MUI_LANGUAGE "Slovak"
!insertmacro MUI_LANGUAGE "Hungarian"
!insertmacro MUI_LANGUAGE "Arabic"
!insertmacro MUI_LANGUAGE "Turkish"
!insertmacro MUI_LANGUAGE "Thai"
!insertmacro MUI_LANGUAGE "Vietnamese"
!macroend
!include "C:\Users\admin\AppData\Local\Temp\t-T8jq5r\3-messages.nsh"
!include "C:\Users\admin\AppData\Local\Temp\t-T8jq5r\4-messages.nsh"
!addplugindir /x86-unicode "C:\Users\admin\AppData\Local\Temp\electron-builder-cache\nsis-resources-3.4.1\nsis-resources-3.4.1-2jx2y\plugins\x86-unicode"
Var newStartMenuLink
Var oldStartMenuLink
Var newDesktopLink
Var oldDesktopLink
Var oldShortcutName
Var oldMenuDirectory
!include "common.nsh"
!include "MUI2.nsh"
!include "multiUser.nsh"
!include "allowOnlyOneInstallerInstance.nsh"
!ifdef BUILD_UNINSTALLER
!ifmacrodef customUnInstallSection
!define MUI_COMPONENTSPAGE_NODESC
!insertmacro MUI_UNPAGE_COMPONENTS
!endif
!endif
!ifdef INSTALL_MODE_PER_ALL_USERS
!ifdef BUILD_UNINSTALLER
RequestExecutionLevel user
!else
RequestExecutionLevel admin
!endif
!else
RequestExecutionLevel user
!endif
!ifdef BUILD_UNINSTALLER
SilentInstall silent
!else
Var appExe
Var launchLink
!endif
!ifdef ONE_CLICK
!include "oneClick.nsh"
!else
!include "assistedInstaller.nsh"
!endif
!insertmacro addLangs
!ifmacrodef customHeader
!insertmacro customHeader
!endif
Function .onInit
Call setInstallSectionSpaceRequired
SetOutPath $INSTDIR
${LogSet} on
!ifmacrodef preInit
!insertmacro preInit
!endif
!ifdef DISPLAY_LANG_SELECTOR
!insertmacro MUI_LANGDLL_DISPLAY
!endif
!ifdef BUILD_UNINSTALLER
WriteUninstaller "${UNINSTALLER_OUT_FILE}"
!insertmacro quitSuccess
!else
!insertmacro check64BitAndSetRegView
!ifdef ONE_CLICK
!insertmacro ALLOW_ONLY_ONE_INSTALLER_INSTANCE
!else
${IfNot} ${UAC_IsInnerInstance}
!insertmacro ALLOW_ONLY_ONE_INSTALLER_INSTANCE
${EndIf}
!endif
!insertmacro initMultiUser
!ifmacrodef customInit
!insertmacro customInit
!endif
!ifmacrodef addLicenseFiles
InitPluginsDir
!insertmacro addLicenseFiles
!endif
!endif
FunctionEnd
!ifndef BUILD_UNINSTALLER
!include "installUtil.nsh"
!endif
Section "install" INSTALL_SECTION_ID
!ifndef BUILD_UNINSTALLER
# If we're running a silent upgrade of a per-machine installation, elevate so extracting the new app will succeed.
# For a non-silent install, the elevation will be triggered when the install mode is selected in the UI,
# but that won't be executed when silent.
!ifndef INSTALL_MODE_PER_ALL_USERS
!ifndef ONE_CLICK
${if} $hasPerMachineInstallation == "1" # set in onInit by initMultiUser
${andIf} ${Silent}
${ifNot} ${UAC_IsAdmin}
ShowWindow $HWNDPARENT ${SW_HIDE}
!insertmacro UAC_RunElevated
${Switch} $0
${Case} 0
${Break}
${Case} 1223 ;user aborted
${Break}
${Default}
MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "Unable to elevate, error $0"
${Break}
${EndSwitch}
Quit
${else}
!insertmacro setInstallModePerAllUsers
${endIf}
${endIf}
!endif
!endif
!include "installSection.nsh"
!endif
SectionEnd
Function setInstallSectionSpaceRequired
!insertmacro setSpaceRequired ${INSTALL_SECTION_ID}
FunctionEnd
!ifdef BUILD_UNINSTALLER
!include "uninstaller.nsh"
!endif

View File

@@ -0,0 +1,8 @@
version: 1.0.0
files:
- url: nl-admin-desktop-1.0.0-win-x64-dev.exe
sha512: XlRSHvc+DBg0UFayyyQjKf3Mv/6CpGz8XGEVRPb9Dx7OXVSotIA5uL85Kq2cU4IqtBsLQRBGrGiqc1b9yHL0lQ==
size: 102116616
path: nl-admin-desktop-1.0.0-win-x64-dev.exe
sha512: XlRSHvc+DBg0UFayyyQjKf3Mv/6CpGz8XGEVRPb9Dx7OXVSotIA5uL85Kq2cU4IqtBsLQRBGrGiqc1b9yHL0lQ==
releaseDate: '2026-08-12T10:14:59.294Z'

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!--
macOS hardenedRuntime 基础 entitlementsElectron 运行必需的三件套)。
本项目无 IM 音视频业务,故不包含摄像头/麦克风条目;
后续若派生项目需要 getUserMedia再补 com.apple.security.device.camera / audio-input。
-->
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

12
apps/desktop/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/**
* electron-vite 构建期注入的环境变量类型声明
* 值来源见 apps/desktop/.env.development / .env.production
*/
interface ImportMetaEnv {
/** /api 转发上游地址MAIN_VITE_ 前缀 = 仅主进程可见) */
readonly MAIN_VITE_API_UPSTREAM?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,54 @@
/**
* 桌面端全局配置常量
*
* 所有「待确认占位项」集中在本文件TODO 标注),后续对齐生产环境后只需改这里,
* 不要把这些常量散落到其它模块(避免上线前漏改)。
*/
/**
* 应用唯一标识
* 必须与 electron-builder.yml 的 appId 完全一致:
* Windows 通知依赖 AppUserModelIDAUMID与安装包快捷方式的 appId 对齐,不一致通知不弹
*/
export const APP_ID = 'com.nladmin.desktop';
/** 产品名:窗口标题、托盘 tooltip、通知标题兜底 */
export const PRODUCT_NAME = 'Nl Admin';
/** app:// 自定义协议的 scheme 与 host生产环境渲染层 Origin 固定为 app://local */
export const APP_SCHEME = 'app';
export const APP_HOST = 'local';
/**
* /api 上游地址:按构建模式从 env 文件注入electron-vite 构建期替换)
* - 联调包 pnpm build:desktop:dev → .env.development固定本地后端
* - 正式包 pnpm build:desktop → .env.productionTODO生产域名确认后改该文件
* 转发语义与 apps/web-antd/vite.config.ts 的 dev 代理完全一致:
* 渲染层请求 /api/xxx → 剥掉 /api 前缀 → 转发到 `${API_UPSTREAM}/xxx`
* 兜底本地后端,保证 env 缺失时包仍可用
*/
export const API_UPSTREAM =
import.meta.env.MAIN_VITE_API_UPSTREAM ?? 'http://127.0.0.1:18006/api';
/** 渲染层请求统一前缀:与桌面端动态下发的 _app.config.js 中 VITE_GLOB_API_URL 保持一致 */
export const API_PREFIX = '/api';
/**
* TODO(待确认)自动更新托管地址1panel 加一个静态站点即可)
* 目录内需存放 electron-builder 产物:
* Windowslatest.yml + exe + blockmapmaclatest-mac.yml + dmg + zip + blockmap
*/
export const UPDATE_URL = 'https://updates.example.com/nl-desktop/';
/** TODO(待确认)mac 未签名时自动更新降级提示跳转的手动下载页,默认复用更新托管地址 */
export const DOWNLOAD_PAGE_URL = UPDATE_URL;
/**
* 开发模式渲染层地址web-antd 的 vite dev server
* 注意端口是 14002apps/web-antd/vite.config.ts 的 server.port 覆盖了 .env.development 的 5666
*/
export const DEV_SERVER_URL =
process.env.ELECTRON_RENDERER_URL ?? 'http://localhost:14002';
/** 全局快捷键:切换主窗口显示/隐藏CommandOrControl 自动映射 Windows=Ctrl / mac=Cmd */
export const GLOBAL_SHORTCUT = 'CommandOrControl+Alt+N';

View File

@@ -0,0 +1,66 @@
/**
* 主进程入口装配协议、窗口、托盘、IPC、快捷键、更新器
* 启动顺序有讲究:协议特权注册必须在 app ready 之前,其余都在 whenReady 之后
*/
import { app } from 'electron';
import log from 'electron-log/main';
import { APP_ID, PRODUCT_NAME } from './config';
import { registerIpcHandlers } from './ipc';
import {
installAppProtocolHandler,
registerAppSchemePrivileges,
} from './protocol';
import { registerGlobalShortcut } from './shortcut';
import { appState } from './state';
import { createTray } from './tray';
import { initUpdater } from './updater';
import {
createMainWindow,
setupSessionSecurity,
showMainWindow,
} from './window';
// 日志落盘到 userData/logs线上问题靠它排查
log.initialize();
log.transports.file.level = 'info';
// Windows 通知/任务栏分组依赖 AUMID必须与 electron-builder.yml 的 appId 完全一致
app.setAppUserModelId(APP_ID);
// app:// 特权协议注册Electron 要求必须在 ready 之前)
registerAppSchemePrivileges();
// 单实例锁:重复启动时唤起已有窗口而不是开第二个实例
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (gotSingleInstanceLock) {
app.on('second-instance', () => showMainWindow());
app.whenReady().then(async () => {
setupSessionSecurity();
installAppProtocolHandler();
registerIpcHandlers();
// --hidden 由开机自启注入(见 utils.setAutoLaunchEnabled创建窗口但静默待在托盘
const startHidden = process.argv.includes('--hidden');
await createMainWindow({ startHidden });
createTray();
registerGlobalShortcut();
initUpdater();
log.info(`[main] ${PRODUCT_NAME} 启动完成 hidden=${startHidden}`);
});
// mac点击 Dock 图标恢复窗口(关窗只是隐藏,窗口实例一直存活)
app.on('activate', () => showMainWindow());
// 所有窗口关闭也不退出:应用常驻托盘,真正退出走托盘菜单 / Cmd+Q
app.on('window-all-closed', () => {
// 故意留空:覆盖 Electron 默认的「非 mac 全关即退出」行为
});
// Cmd+Q / app.quit() 都会先经过这里:放行窗口 close不再拦截为隐藏
app.on('before-quit', () => {
appState.quitting = true;
});
} else {
app.quit();
}

View File

@@ -0,0 +1,223 @@
/**
* IPC 通道注册:渲染层 window.desktopAPI 各能力的主进程实现
* 安全约定:所有 handler 统一先校验 sender 来源app://local 或本地 dev server
* 防止意外加载的第三方页面调用桌面能力
*/
import type { IpcMainInvokeEvent } from 'electron';
import {
app,
BrowserWindow,
ipcMain,
nativeImage,
Notification,
} from 'electron';
import log from 'electron-log/main';
import { PRODUCT_NAME } from './config';
import { SETTING_KEYS, settingsStore } from './store';
import { refreshTrayMenu } from './tray';
import { checkUpdateManually, quitAndInstallNow } from './updater';
import {
getAutoLaunchEnabled,
getResourcePath,
setAutoLaunchEnabled,
} from './utils';
import {
getMainWindow,
isTrustedWebContentsUrl,
showMainWindow,
} from './window';
/** 渲染层 notify() 的入参结构(与 preload 保持同步) */
interface NotifyOptions {
body?: string;
flash?: boolean;
route?: string;
silent?: boolean;
title: string;
}
/** 渲染层 printHTML() 的入参结构(与 preload 保持同步) */
interface PrintHTMLOptions {
deviceName?: string;
html: string;
landscape?: boolean;
silent?: boolean;
}
/**
* 带来源校验的 ipcMain.handle 包装
* 为什么不用裸 ipcMain.handle每个通道都要校验 sender包一层避免每处复制粘贴校验代码
*/
function handle(
channel: string,
handler: (event: IpcMainInvokeEvent, ...args: any[]) => unknown,
): void {
ipcMain.handle(channel, (event, ...args) => {
const senderUrl = event.senderFrame?.url ?? '';
if (!isTrustedWebContentsUrl(senderUrl)) {
log.warn('[ipc] 拒绝不可信来源的 IPC 调用:', channel, senderUrl);
throw new Error('IPC 调用来源不可信');
}
return handler(event, ...args);
});
}
/** 注册全部 IPC 通道,应用启动时调用一次 */
export function registerIpcHandlers(): void {
handle('desktop:get-version', () => app.getVersion());
// ---- 系统通知 ----
handle('desktop:notify', (_event, options: NotifyOptions) => {
showNotification(options);
});
// ---- 未读角标dataUrl 由 preload 的 canvas 绘制,仅 Windows 用到) ----
handle('desktop:set-badge', (_event, count: number, dataUrl: string) => {
setBadge(count, dataUrl);
});
// ---- 任务栏闪烁 ----
handle('desktop:flash-frame', (_event, flag: boolean) => {
const win = getMainWindow();
if (!win) return;
// 已聚焦时闪烁没有意义还会造成任务栏图标闪一下的视觉bug
if (flag && win.isFocused()) return;
win.flashFrame(!!flag);
});
// ---- 开机自启 ----
handle('desktop:get-auto-launch', () => getAutoLaunchEnabled());
handle('desktop:set-auto-launch', (_event, enabled: boolean) => {
const result = setAutoLaunchEnabled(!!enabled);
// 渲染层改了自启后,托盘菜单的勾选状态要同步
refreshTrayMenu();
return result;
});
// ---- 静默打印 ----
handle('desktop:get-printers', async () => {
const win = getMainWindow();
return win ? await win.webContents.getPrintersAsync() : [];
});
handle('desktop:print-html', (_event, options: PrintHTMLOptions) =>
printHTML(options),
);
// ---- 桌面端本地设置 ----
handle('desktop:get-setting', (_event, key: string) => settingsStore.get(key));
handle('desktop:set-setting', (_event, key: string, value: unknown) => {
settingsStore.set(key, value);
});
// ---- 更新 ----
handle('desktop:check-update', () => checkUpdateManually());
handle('desktop:quit-and-install', () => quitAndInstallNow());
}
/**
* 弹系统通知:点击唤起主窗口,携带 route 时再通知渲染层跳转站内路由
* Windows 能弹的前提app.setAppUserModelId 与安装包 appId 一致(入口处已设置)
*/
function showNotification(options: NotifyOptions): void {
if (!Notification.isSupported()) {
log.warn('[ipc] 当前系统不支持通知');
return;
}
const notification = new Notification({
body: options.body ?? '',
icon: nativeImage.createFromPath(getResourcePath('icon.png')),
silent: options.silent ?? false,
title: options.title || PRODUCT_NAME,
});
notification.on('click', () => {
showMainWindow();
if (options.route) {
getMainWindow()?.webContents.send('desktop:navigate', options.route);
}
});
notification.show();
// 默认行为窗口未聚焦时同步闪烁任务栏mac 表现为 Dock 弹跳),聚焦后自动清除
const win = getMainWindow();
if ((options.flash ?? true) && win && !win.isFocused()) {
win.flashFrame(true);
}
}
/**
* 设置未读角标,按平台分支:
* mac 用 Dock 原生数字角标Windows 用 preload 画好的红底数字图做任务栏覆盖图;
* Linux 尽力而为走 setBadgeCount仅部分桌面环境支持
*/
function setBadge(count: number, dataUrl: string): void {
const normalized = Math.max(0, Math.floor(count) || 0);
if (process.platform === 'darwin') {
app.dock?.setBadge(normalized > 0 ? String(normalized) : '');
return;
}
if (process.platform === 'win32') {
const win = getMainWindow();
if (!win) return;
if (normalized > 0 && dataUrl) {
win.setOverlayIcon(
nativeImage.createFromDataURL(dataUrl),
`${normalized} 条未读`,
);
} else {
win.setOverlayIcon(null, '');
}
return;
}
app.setBadgeCount(normalized);
}
/**
* 打印一段完整 HTML
* 实现:隐藏窗口加载 HTMLbase64 data URL 避免特殊字符截断)→ webContents.print
* 静默打印必须有明确的打印机名(入参或本地设置),否则自动降级为弹系统打印对话框
*/
async function printHTML(
options: PrintHTMLOptions,
): Promise<{ message?: string; success: boolean }> {
const deviceName =
options.deviceName ||
(settingsStore.get(SETTING_KEYS.printerDeviceName) as string | undefined);
const silent = (options.silent ?? true) && !!deviceName;
const printWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
try {
const dataUrl = `data:text/html;charset=utf-8;base64,${Buffer.from(
options.html,
'utf8',
).toString('base64')}`;
await printWindow.loadURL(dataUrl);
return await new Promise((resolvePrint) => {
printWindow.webContents.print(
{
deviceName: silent ? deviceName : undefined,
landscape: options.landscape ?? false,
printBackground: true,
silent,
},
(success, failureReason) => {
resolvePrint({
message: success ? undefined : failureReason,
success,
});
},
);
});
} catch (error) {
log.error('[ipc] 打印失败:', error);
return { message: String(error), success: false };
} finally {
if (!printWindow.isDestroyed()) printWindow.destroy();
}
}

View File

@@ -0,0 +1,223 @@
/**
* app:// 自定义协议层——桌面端的「内置 nginx」
*
* 生产环境渲染进程加载 app://local/,本模块负责三件事:
* 1. 静态文件服务:把 web-antd 的生产 distextraResources 拷入 resources/web按路径吐出
* 并做 SPA fallback等价 nginx try_files本项目生产是 hash 路由fallback 仅兜底)
* 2. 动态覆盖 /_app.config.js利用 vben 的运行时配置机制,把 VITE_GLOB_API_URL 改写为 /api
* 使桌面端 API 指向与网页端部署解耦(网页端 .env.production 完全不用动)
* 3. 转发 /api/*:用主进程 net.fetch 转发到后端上游(剥掉 /api 前缀,语义与
* apps/web-antd/vite.config.ts 的 dev 代理 rewrite 一致),因为是主进程发起、
* 与页面同源app://local所以后端无需配置 CORS
*/
import { readFile, stat } from 'node:fs/promises';
import { extname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
import { app, net, protocol } from 'electron';
import log from 'electron-log/main';
import { API_PREFIX, API_UPSTREAM, APP_HOST, APP_SCHEME } from './config';
/** 常见静态资源的 MIME 映射dist 内实际会出现的类型) */
const MIME_TYPES: Record<string, string> = {
'.avif': 'image/avif',
'.css': 'text/css; charset=utf-8',
'.eot': 'application/vnd.ms-fontobject',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.otf': 'font/otf',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.xml': 'application/xml; charset=utf-8',
};
/**
* vben 运行时配置文件的路径模式
* vben 5.x 产出 `_app-config-{version}-{hash}.js`(本仓库实测),历史版本为 `_app.config.js`
* 用模式匹配兼容两种命名,避免升级 vben 后改写逻辑悄悄失效
*/
const APP_CONFIG_FILE_RE = /^\/_app[.-]config[^/]*\.js$/;
/**
* 注册 app:// 为特权协议
* 必须在 app ready 之前调用Electron 硬性要求),否则协议没有 standard/secure 等特权:
* - standard拥有标准 Origin 语义localStorage / IndexedDB 才可用(登录态依赖)
* - secure视为 Secure Contextclipboard 等现代 Web 能力才可用
* - supportFetchAPI + corsEnabled允许渲染层 fetch/XHR 请求本协议axios 调 /api 必需)
* - stream允许流式响应codeCachev8 代码缓存加速二次启动
*/
export function registerAppSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
codeCache: true,
},
},
]);
}
/**
* 计算 web 静态资源根目录
* 打包后resources/webelectron-builder extraResources 从 ../web-antd/dist 拷入)
* 开发态:直接指向 apps/web-antd/dist用于本地不打包也能验证 app:// 生产链路)
*/
function getWebRoot(): string {
return app.isPackaged
? join(process.resourcesPath, 'web')
: resolve(app.getAppPath(), '../web-antd/dist');
}
/**
* 安装 app:// 协议处理器,必须在 app ready 之后调用
*/
export function installAppProtocolHandler(): void {
protocol.handle(APP_SCHEME, async (request) => {
try {
const url = new URL(request.url);
// 只认 app://local其它 host 一律 404避免协议被意外扩散使用
if (url.host !== APP_HOST) {
return new Response('Not Found', { status: 404 });
}
const pathname = decodeURIComponent(url.pathname);
// 1) /api 前缀 → 转发到后端上游
if (pathname === API_PREFIX || pathname.startsWith(`${API_PREFIX}/`)) {
return await forwardApiRequest(request, url);
}
// 2) vben 运行时配置文件 → 动态改写 API 地址后下发
if (APP_CONFIG_FILE_RE.test(pathname)) {
return await serveAppConfigOverride(pathname);
}
// 3) 其余走静态文件服务 + SPA fallback
return await serveStatic(pathname);
} catch (error) {
log.error('[protocol] 处理请求异常:', request.url, error);
return new Response('Internal Error', { status: 500 });
}
});
}
/**
* 转发 /api/* 请求到后端上游
* 为什么整体缓冲 body 再转发:管理后台的上传体量可控(几 MB 级),
* 缓冲比流式转发(需处理 duplex half 兼容性)简单可靠得多
*/
async function forwardApiRequest(request: Request, url: URL): Promise<Response> {
// 剥掉 /api 前缀,保留其后的路径与查询串
const restPath = url.pathname.slice(API_PREFIX.length) || '/';
const upstream = API_UPSTREAM.replace(/\/+$/, '');
const target = `${upstream}${restPath}${url.search}`;
const headers = new Headers(request.headers);
// Origin/Referer 是 app://local对上游没有意义转发时移除避免干扰后端框架的来源判断
headers.delete('origin');
headers.delete('referer');
try {
const hasBody = !['GET', 'HEAD'].includes(request.method.toUpperCase());
return await net.fetch(target, {
method: request.method,
headers,
body: hasBody ? await request.arrayBuffer() : undefined,
redirect: 'follow',
});
} catch (error) {
log.error('[protocol] API 转发失败:', target, error);
// 返回 vben 响应拦截器可识别的结构,前端会用 message 弹错误提示
return new Response(
JSON.stringify({ code: 502, message: '无法连接后端服务,请检查网络后重试' }),
{
status: 502,
headers: { 'content-type': 'application/json; charset=utf-8' },
},
);
}
}
/**
* 动态下发 vben 运行时配置文件
* vben 生产构建会产出该文件定义 window._VBEN_ADMIN_PRO_APP_CONF_含 VITE_GLOB_API_URL
* 桌面端读取 dist 里的原文件、只把 VITE_GLOB_API_URL 改写为 /api其余运行时配置键原样保留
* 这样 axios 的 baseURL 变成同源相对路径,全部命中上面的 /api 转发
*/
async function serveAppConfigOverride(pathname: string): Promise<Response> {
const filePath = join(getWebRoot(), pathname.replace(/^\/+/, ''));
let content: string;
try {
const raw = await readFile(filePath, 'utf8');
content = raw.replace(
/("VITE_GLOB_API_URL"\s*:\s*")[^"]*(")/,
`$1${API_PREFIX}$2`,
);
log.info('[protocol] 运行时配置已改写 VITE_GLOB_API_URL → /api:', pathname);
} catch {
// 文件读不到时兜底生成最小可用配置(理论上 vben 构建一定会产出)
log.warn('[protocol] 运行时配置文件缺失,使用兜底配置:', pathname);
content = `window._VBEN_ADMIN_PRO_APP_CONF_={"VITE_GLOB_API_URL":"${API_PREFIX}"};Object.freeze(window._VBEN_ADMIN_PRO_APP_CONF_);`;
}
return new Response(content, {
headers: {
'content-type': 'text/javascript; charset=utf-8',
// 运行时配置不缓存,保证改上游地址后重启即生效
'cache-control': 'no-cache',
},
});
}
/**
* 静态文件服务 + SPA fallback
* 带目录穿越防护:解析后的物理路径必须仍在 webRoot 内
*/
async function serveStatic(pathname: string): Promise<Response> {
const webRoot = normalize(getWebRoot());
const relPath = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
let filePath = normalize(join(webRoot, relPath));
// 目录穿越防护:必须用 path.relative 判断真实包含关系。
// 不能用 filePath.startsWith(webRoot) —— 那样 `resources\web` 的同级目录
// `resources\website` 也会命中前缀,从而逃出 web 根读到相邻目录文件
const rel = relative(webRoot, filePath);
if (rel.startsWith('..') || isAbsolute(rel)) {
return new Response('Forbidden', { status: 403 });
}
// 文件不存在或是目录时回退 index.html等价 nginx try_files $uri /index.html
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat || fileStat.isDirectory()) {
filePath = join(webRoot, 'index.html');
}
try {
const data = await readFile(filePath);
const mime =
MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
return new Response(data as unknown as BodyInit, {
headers: { 'content-type': mime },
});
} catch (error) {
// 连 index.html 都没有:一般是没先构建 web-antd给出可读的提示页
log.error('[protocol] 静态资源读取失败:', filePath, error);
return new Response(
'<h3>未找到 web 构建产物</h3><p>请先在仓库根目录执行 <code>pnpm build</code> 生成 apps/web-antd/dist 后重试。</p>',
{
status: 404,
headers: { 'content-type': 'text/html; charset=utf-8' },
},
);
}
}

View File

@@ -0,0 +1,33 @@
/**
* 全局快捷键:一键唤起/隐藏主窗口(默认 CommandOrControl+Alt+N见 config.ts
*/
import { app, globalShortcut } from 'electron';
import log from 'electron-log/main';
import { GLOBAL_SHORTCUT } from './config';
import { getMainWindow, showMainWindow } from './window';
/**
* 注册全局快捷键
* 快捷键可能被其它程序占用:注册失败只记录告警,绝不影响应用启动
*/
export function registerGlobalShortcut(): void {
const ok = globalShortcut.register(GLOBAL_SHORTCUT, () => {
const win = getMainWindow();
if (!win) return;
// 可见且聚焦 → 隐藏;否则(隐藏/最小化/失焦)→ 唤起到前台
if (win.isVisible() && win.isFocused()) {
win.hide();
} else {
showMainWindow();
}
});
if (!ok) {
log.warn(
'[shortcut] 全局快捷键注册失败(可能被其它程序占用):',
GLOBAL_SHORTCUT,
);
}
// 退出前必须注销,否则某些平台会残留系统级占用
app.on('will-quit', () => globalShortcut.unregisterAll());
}

View File

@@ -0,0 +1,11 @@
/**
* 主进程共享运行时状态
* 单独成模块是为了避免 window.ts / tray.ts / updater.ts 之间互相 import 造成循环依赖
*/
export const appState = {
/**
* 是否处于「真正退出」流程(托盘菜单退出 / mac Cmd+Q / 更新重启安装)
* false 时点击窗口关闭按钮只隐藏到托盘,不退出应用
*/
quitting: false,
};

View File

@@ -0,0 +1,16 @@
/**
* 桌面端本地设置持久化electron-store落盘到 userData/settings.json
* 存放:静默打印选中的打印机、托盘首次隐藏提示标记等轻量配置
* 统一从这里取单例,禁止各模块自行 new Store 造成多份文件
*/
import Store from 'electron-store';
/** 设置项的键值约定(集中声明避免魔法字符串散落) */
export const SETTING_KEYS = {
/** 静默打印默认打印机名称 */
printerDeviceName: 'printer.deviceName',
/** Windows 关闭进托盘的首次提示是否已展示 */
closeHintShown: 'tray.closeHintShown',
} as const;
export const settingsStore = new Store({ name: 'settings' });

View File

@@ -0,0 +1,66 @@
/**
* 系统托盘:应用常驻入口
* Windows关闭窗口后靠托盘活着单击托盘还原窗口
* mac托盘菜单栏图标仅提供快捷菜单窗口恢复主要靠 Dock 的 activate 事件
*/
import { app, Menu, nativeImage, Tray } from 'electron';
import { PRODUCT_NAME } from './config';
import { appState } from './state';
import { checkUpdateManually } from './updater';
import { getAutoLaunchEnabled, getResourcePath, setAutoLaunchEnabled } from './utils';
import { showMainWindow } from './window';
/** 托盘单例(必须持有引用,否则会被 GC 导致图标消失) */
let tray: null | Tray = null;
/**
* 创建托盘图标与菜单
* 托盘直接用品牌应用图标缩小resources/icon.png年糕猫+奶酪)
*/
export function createTray(): Tray {
// TODO(待确认)mac 后续可换 Template 单色托盘图setTemplateImage 随菜单栏深浅色自动反色)
const icon = nativeImage
.createFromPath(getResourcePath('icon.png'))
.resize({ height: 16, width: 16 });
tray = new Tray(icon);
tray.setToolTip(PRODUCT_NAME);
refreshTrayMenu();
// Windows 惯例单击托盘直接还原主窗口mac 惯例是单击弹菜单,不抢行为
if (process.platform !== 'darwin') {
tray.on('click', () => showMainWindow());
}
return tray;
}
/**
* 重建托盘右键菜单
* 单独导出是因为「开机自启」勾选状态可能被渲染层desktopAPI.setAutoLaunch改掉
* 改完需要同步刷新菜单勾选态
*/
export function refreshTrayMenu(): void {
if (!tray) return;
const menu = Menu.buildFromTemplate([
{ click: () => showMainWindow(), label: '显示主窗口' },
{ type: 'separator' },
{
checked: getAutoLaunchEnabled(),
click: (item) => {
setAutoLaunchEnabled(item.checked);
},
label: '开机自启',
type: 'checkbox',
},
{ click: () => checkUpdateManually(), label: '检查更新' },
{ type: 'separator' },
{
click: () => {
// 标记真正退出,绕过窗口 close 事件里的「隐藏到托盘」拦截
appState.quitting = true;
app.quit();
},
label: '退出',
},
]);
tray.setContextMenu(menu);
}

View File

@@ -0,0 +1,154 @@
/**
* 自动更新electron-updatergeneric provider消费静态托管的 latest*.yml + 安装包)
* Windows 消费 latest.yml + exemac 消费 latest-mac.yml + zip
* mac 未签名包无法通过更新签名校验:捕获后降级为「前往下载页手动安装」提示
*/
import type { UpdateInfo } from 'electron-updater';
import { app, dialog, shell } from 'electron';
import log from 'electron-log/main';
import electronUpdater from 'electron-updater';
import { DOWNLOAD_PAGE_URL, PRODUCT_NAME } from './config';
import { appState } from './state';
import { getMainWindow } from './window';
// electron-updater 是 CJS 包ESM 下先整体导入再解构named import 在部分打包场景会失败)
const { autoUpdater } = electronUpdater;
/** 是否用户手动触发的检查:手动才弹「已是最新/失败」反馈,启动时的静默检查不打扰用户 */
let manualCheck = false;
/** 防止重复初始化事件监听 */
let initialized = false;
/** 把更新流程事件转发给渲染层web 侧可用 desktopAPI.onUpdateEvent 展示下载进度) */
function sendUpdateEvent(type: string, payload?: unknown): void {
getMainWindow()?.webContents.send('desktop:update-event', { payload, type });
}
/**
* 初始化更新器:注册事件 + 启动延时静默检查
* 只在打包环境生效(开发态没有 app-update.yml检查必然报错
*/
export function initUpdater(): void {
if (!app.isPackaged || initialized) return;
initialized = true;
autoUpdater.logger = log;
autoUpdater.autoDownload = true;
// 用户选「退出时安装」时靠这个兜底自动装
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('checking-for-update', () => sendUpdateEvent('checking'));
autoUpdater.on('update-available', (info: UpdateInfo) =>
sendUpdateEvent('available', { version: info.version }),
);
autoUpdater.on('update-not-available', () => {
sendUpdateEvent('not-available');
if (manualCheck) {
dialog.showMessageBox({
message: '当前已是最新版本',
title: PRODUCT_NAME,
type: 'info',
});
}
manualCheck = false;
});
autoUpdater.on('download-progress', (progress) =>
sendUpdateEvent('progress', {
percent: progress.percent,
transferred: progress.transferred,
total: progress.total,
}),
);
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
sendUpdateEvent('downloaded', { version: info.version });
void promptInstall(info);
});
autoUpdater.on('error', (error) => {
const message = String(error?.message ?? error);
sendUpdateEvent('error', { message });
log.error('[updater] 更新出错:', message);
// mac 未签名包的签名校验失败:降级为手动下载引导
const isMacSignatureIssue =
process.platform === 'darwin' &&
/code signature|codesign|not signed/i.test(message);
if (isMacSignatureIssue) {
void promptManualDownload();
} else if (manualCheck) {
dialog.showMessageBox({
detail: message,
message: '检查更新失败',
title: PRODUCT_NAME,
type: 'warning',
});
}
manualCheck = false;
});
// 启动 8 秒后静默检查:等窗口/协议就绪,也避免抢启动期资源
setTimeout(() => {
autoUpdater
.checkForUpdates()
.catch((error) => log.warn('[updater] 启动静默检查失败:', error));
}, 8000);
}
/** 手动检查更新(托盘菜单 / desktopAPI.checkUpdate 共用入口) */
export function checkUpdateManually(): void {
if (!app.isPackaged) {
dialog.showMessageBox({
message: '开发模式不支持检查更新',
title: PRODUCT_NAME,
type: 'info',
});
return;
}
manualCheck = true;
autoUpdater
.checkForUpdates()
.catch((error) => log.warn('[updater] 手动检查失败:', error));
}
/** 立即重启安装(更新属于真正退出,先置 quitting 绕过「关闭进托盘」拦截) */
export function quitAndInstallNow(): void {
appState.quitting = true;
// isSilent=false 走正常安装界面isForceRunAfter=true 装完自动拉起新版本
autoUpdater.quitAndInstall(false, true);
}
/** 下载完成后的安装确认弹窗 */
async function promptInstall(info: UpdateInfo): Promise<void> {
const options = {
buttons: ['立即重启安装', '退出时安装'],
cancelId: 1,
defaultId: 0,
detail: '选择「退出时安装」则下次退出应用时自动完成升级。',
message: `新版本 ${info.version} 已下载完成,是否立即重启安装?`,
title: PRODUCT_NAME,
type: 'info' as const,
};
const win = getMainWindow();
const { response } = win
? await dialog.showMessageBox(win, options)
: await dialog.showMessageBox(options);
if (response === 0) {
quitAndInstallNow();
}
}
/** mac 未签名降级:引导用户去下载页手动装新版本 */
async function promptManualDownload(): Promise<void> {
const { response } = await dialog.showMessageBox({
buttons: ['打开下载页', '取消'],
cancelId: 1,
defaultId: 0,
detail: '当前安装包未进行代码签名,无法自动更新,请前往下载页手动安装最新版本。',
message: '检测到新版本',
title: PRODUCT_NAME,
type: 'info',
});
if (response === 0) {
void shell.openExternal(DOWNLOAD_PAGE_URL);
}
}

View File

@@ -0,0 +1,35 @@
/**
* 主进程通用小工具
*/
import { join } from 'node:path';
import { app } from 'electron';
/**
* 取运行时资源文件的绝对路径(图标等)
* 打包后resources/ 下electron-builder extraResources 拷入)
* 开发态apps/desktop/resources/ 源目录
*/
export function getResourcePath(rel: string): string {
return app.isPackaged
? join(process.resourcesPath, rel)
: join(app.getAppPath(), 'resources', rel);
}
/** 读取开机自启状态Windows 读注册表mac 读系统「登录项」) */
export function getAutoLaunchEnabled(): boolean {
return app.getLoginItemSettings().openAtLogin;
}
/**
* 设置开机自启
* args --hidden 仅 Windows 生效:自启时静默进托盘不弹主窗口(入口处解析该参数);
* mac 走 SMAppService系统设置 → 登录项 可见),不支持自定义参数,自启后正常显示窗口
*/
export function setAutoLaunchEnabled(enabled: boolean): boolean {
app.setLoginItemSettings({
openAtLogin: enabled,
args: enabled ? ['--hidden'] : [],
});
return getAutoLaunchEnabled();
}

View File

@@ -0,0 +1,187 @@
/**
* 主窗口管理:创建、加载策略、安全基线、关闭行为
*/
import { join } from 'node:path';
import {
app,
BrowserWindow,
nativeImage,
Notification,
session,
shell,
} from 'electron';
import log from 'electron-log/main';
import {
APP_HOST,
APP_SCHEME,
DEV_SERVER_URL,
PRODUCT_NAME,
} from './config';
import { appState } from './state';
import { SETTING_KEYS, settingsStore } from './store';
import { getResourcePath } from './utils';
/** 主窗口单例引用(应用生命周期内只有一个主窗口) */
let mainWindow: BrowserWindow | null = null;
/** 取主窗口(已销毁则返回 null调用方需自行兜底 */
export function getMainWindow(): BrowserWindow | null {
return mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
}
/** 显示并聚焦主窗口(托盘单击 / 通知点击 / 二次启动 / mac Dock 激活共用) */
export function showMainWindow(): void {
const win = getMainWindow();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
/**
* 判断某个 URL 是否属于「自己人」页面
* 用途will-navigate 导航拦截、IPC sender 来源校验
* 生产只信 app://local开发态额外放行本地 vite dev serverlocalhost 与 127.0.0.1 等价)
*/
export function isTrustedWebContentsUrl(url: string): boolean {
if (url.startsWith(`${APP_SCHEME}://${APP_HOST}`)) return true;
if (!app.isPackaged) {
return (
url.startsWith(DEV_SERVER_URL) ||
url.startsWith(DEV_SERVER_URL.replace('localhost', '127.0.0.1'))
);
}
return false;
}
/**
* 会话级安全设置:权限请求默认拒绝,仅放行白名单
* 本项目无 IM 音视频media摄像头/麦克风)一律拒绝;派生项目需要时再把 'media' 加进白名单
*/
export function setupSessionSecurity(): void {
const allowedPermissions = new Set([
'clipboard-read',
'clipboard-sanitized-write',
'fullscreen',
'notifications',
]);
session.defaultSession.setPermissionRequestHandler(
(_webContents, permission, callback) => {
callback(allowedPermissions.has(permission));
},
);
}
/**
* 创建主窗口
* @param options.startHidden 开机自启(--hidden时为 true创建后不显示、静默待在托盘
*/
export async function createMainWindow(options: {
startHidden: boolean;
}): Promise<BrowserWindow> {
const win = new BrowserWindow({
title: PRODUCT_NAME,
width: 1440,
height: 900,
minWidth: 1024,
minHeight: 680,
// 先隐藏ready-to-show 再展示,避免加载期白屏闪烁
show: false,
autoHideMenuBar: true,
// mac 的应用图标由打包配置决定,这里只给 Windows/Linux 的窗口图标
icon:
process.platform === 'darwin'
? undefined
: nativeImage.createFromPath(getResourcePath('icon.png')),
backgroundColor: '#f5f5f5',
webPreferences: {
// 安全基线:隔离 + 沙箱 + 禁 node一律不许放开渲染层是可热替换的 web 产物)
preload: join(import.meta.dirname, '../preload/index.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
spellcheck: false,
},
});
mainWindow = win;
win.on('ready-to-show', () => {
if (!options.startHidden) win.show();
});
// 渲染层 window.open 的外链统一交给系统浏览器,壳内不开新窗口
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
}
return { action: 'deny' };
});
// 页面内直接跳转外部地址也拦下来走系统浏览器,防止壳被当成通用浏览器用
win.webContents.on('will-navigate', (event, url) => {
if (!isTrustedWebContentsUrl(url)) {
event.preventDefault();
// 与 setWindowOpenHandler 保持一致:只把 http(s) 交给系统浏览器,
// file:// / 自定义协议等一律仅拦截不外开,避免被诱导打开危险协议
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
}
}
});
// 关闭按钮 = 隐藏到托盘(双平台一致;真正退出走托盘菜单/Cmd+Q见 state.quitting
win.on('close', (event) => {
if (appState.quitting) return;
event.preventDefault();
win.hide();
showFirstHideHint();
});
// 拿到焦点时清掉任务栏闪烁提醒
win.on('focus', () => win.flashFrame(false));
if (app.isPackaged) {
// 生产:走 app:// 协议加载 web-antd 的 disthash 路由,入口固定 index.html
await win.loadURL(`${APP_SCHEME}://${APP_HOST}/index.html`);
} else {
// 开发:连 web-antd 的 vite dev server起得比 vite 快时自动重试等它就绪
loadDevServerWithRetry(win);
}
return win;
}
/**
* 开发模式加载 dev server失败每秒重试dev:desktop 并行启动时 electron 通常比 vite 先就绪)
*/
function loadDevServerWithRetry(win: BrowserWindow, attempt = 0): void {
if (win.isDestroyed()) return;
win.webContents.once('did-fail-load', (_event, code, desc) => {
if (attempt >= 120) {
log.error('[window] 连接 dev server 失败已放弃:', DEV_SERVER_URL, code, desc);
return;
}
setTimeout(() => loadDevServerWithRetry(win, attempt + 1), 1000);
});
win.loadURL(DEV_SERVER_URL).catch(() => {
// loadURL 的 promise 失败与 did-fail-load 重复,重试统一由上面的事件驱动
});
}
/**
* Windows 首次「关闭进托盘」时弹一条系统通知告知用户,只提示一次(标记持久化)
* mac 关窗隐藏是系统惯例,无需提示
*/
function showFirstHideHint(): void {
if (process.platform !== 'win32') return;
if (settingsStore.get(SETTING_KEYS.closeHintShown)) return;
settingsStore.set(SETTING_KEYS.closeHintShown, true);
if (Notification.isSupported()) {
new Notification({
title: PRODUCT_NAME,
body: '已最小化到系统托盘,单击托盘图标可恢复窗口',
icon: nativeImage.createFromPath(getResourcePath('icon.png')),
}).show();
}
}

View File

@@ -0,0 +1,121 @@
/**
* preload通过 contextBridge 向渲染层暴露 window.desktopAPI
*
* 运行在 sandbox 沙箱中(只能使用 electron 受限模块 + DOM
* 所有能力都走 ipcRenderer.invoke 白名单通道,渲染层拿不到任何 Node 能力。
* web 侧的类型声明见 apps/web-antd/src/types/desktop.d.ts两边字段需保持同步
*/
import type { IpcRendererEvent } from 'electron';
import { contextBridge, ipcRenderer } from 'electron';
/**
* 用 canvas 绘制 Windows 任务栏角标覆盖图(红底白字数字),返回 dataURL
* 为什么在 preload 画:主进程没有 DOM canvasnativeImage 也不能绘制文字;
* preload 跑在渲染进程里有完整 DOM 能力,画完把 dataURL 交给主进程 setOverlayIcon
*/
function drawBadgeDataURL(count: number): string {
const size = 32;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return '';
const text = count > 99 ? '99+' : String(count);
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.fillStyle = '#f5222d';
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${text.length > 2 ? 13 : 18}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, size / 2, size / 2 + 1);
return canvas.toDataURL('image/png');
}
/** 订阅主进程推送事件的通用封装,返回取消订阅函数(组件卸载时记得调用防止泄漏) */
function subscribe<T>(
channel: string,
callback: (payload: T) => void,
): () => void {
const listener = (_event: IpcRendererEvent, payload: T) => callback(payload);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
}
const desktopAPI = {
/** 恒为 trueweb 侧用它判断是否运行在 Electron 壳内 */
isElectron: true as const,
/** 当前平台win32 / darwin / linux渲染层做平台差异 UI 时用) */
platform: process.platform,
/** 应用版本号package.json version用于「关于」展示 */
getVersion: (): Promise<string> => ipcRenderer.invoke('desktop:get-version'),
/**
* 发系统通知route 传入站内路由(如 /notice/list时点击通知会唤起窗口并跳转
* flash 默认 true窗口未聚焦时同时闪烁任务栏mac 表现为 Dock 弹跳)
*/
notify: (options: {
title: string;
body?: string;
route?: string;
silent?: boolean;
flash?: boolean;
}): Promise<void> => ipcRenderer.invoke('desktop:notify', options),
/** 设置未读角标数0 清除mac 用 Dock 原生数字Windows 用 canvas 画的覆盖图 */
setBadge: (count: number): Promise<void> => {
const dataUrl =
count > 0 && process.platform === 'win32' ? drawBadgeDataURL(count) : '';
return ipcRenderer.invoke('desktop:set-badge', count, dataUrl);
},
/** 手动控制任务栏闪烁一般用不到notify 已内置) */
flashFrame: (flag = true): Promise<void> =>
ipcRenderer.invoke('desktop:flash-frame', flag),
/** 读取开机自启状态 */
getAutoLaunch: (): Promise<boolean> =>
ipcRenderer.invoke('desktop:get-auto-launch'),
/** 设置开机自启(--hidden 静默启动进托盘) */
setAutoLaunch: (enabled: boolean): Promise<boolean> =>
ipcRenderer.invoke('desktop:set-auto-launch', enabled),
/** 枚举系统打印机列表 */
getPrinters: (): Promise<unknown[]> =>
ipcRenderer.invoke('desktop:get-printers'),
/**
* 打印一段完整 HTML不传 deviceName 时读持久化的默认打印机;
* silent=true 静默打印false 弹系统打印对话框
*/
printHTML: (options: {
html: string;
deviceName?: string;
silent?: boolean;
landscape?: boolean;
}): Promise<{ message?: string; success: boolean }> =>
ipcRenderer.invoke('desktop:print-html', options),
/** 读桌面端本地设置(如默认打印机) */
getSetting: (key: string): Promise<unknown> =>
ipcRenderer.invoke('desktop:get-setting', key),
/** 写桌面端本地设置 */
setSetting: (key: string, value: unknown): Promise<void> =>
ipcRenderer.invoke('desktop:set-setting', key, value),
/** 手动检查更新(托盘菜单也有同款入口) */
checkUpdate: (): Promise<void> => ipcRenderer.invoke('desktop:check-update'),
/** 更新下载完成后立即重启安装 */
quitAndInstall: (): Promise<void> =>
ipcRenderer.invoke('desktop:quit-and-install'),
/** 订阅「点击通知要求跳转路由」事件web 侧收到后自行 router.push */
onNavigate: (callback: (route: string) => void): (() => void) =>
subscribe<string>('desktop:navigate', callback),
/** 订阅更新流程事件checking / available / progress / downloaded 等),用于展示进度 */
onUpdateEvent: (
callback: (event: { payload?: unknown; type: string }) => void,
): (() => void) =>
subscribe<{ payload?: unknown; type: string }>(
'desktop:update-event',
callback,
),
};
contextBridge.exposeInMainWorld('desktopAPI', desktopAPI);
/** 导出类型供主进程侧引用(仅类型层面,运行时无依赖) */
export type DesktopAPI = typeof desktopAPI;

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src", "electron.vite.config.ts"]
}

View File

@@ -43,8 +43,13 @@
"@vueuse/core": "catalog:",
"ant-design-vue": "catalog:",
"dayjs": "catalog:",
"jszip": "^3.10.1",
"markdown-it": "^15.0.0",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

View File

@@ -95,9 +95,12 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
if (error?.response?.data.code === 401) {
console.log('wwwwwwwwwwww')
return doReAuthenticate();
// 后端未授权时 HTTP 恒 200、业务码为 401jexpire这里需触发重新登录
// 注意不要 return doReAuthenticate() 的 Promise否则拦截器会把这次失败当成已解决
// 调用方拿不到 reject故只触发重认证后直接 return让拦截器维持拒绝语义
if (error?.response?.data?.code === 401) {
doReAuthenticate();
return;
}
// 当前mock接口返回的错误字段是 error 或者 message
const responseData = error?.response?.data ?? {};

View File

@@ -0,0 +1,71 @@
<script lang="ts" setup>
// 液态毛玻璃卡片Apple Liquid Glass 质感):
// 半透明渐变底 + backdrop blur + 顶部内嵌高光边;必须放在 GlassScene或其它有色背景上使用
// 点击事件走原生 attrs 透传(根节点单一),业务侧直接 @click 即可
withDefaults(
defineProps<{
/** 可按压:悬浮上浮 + 主色描边(卡片整体可点时开) */
pressable?: boolean;
}>(),
{ pressable: false },
);
defineOptions({ name: 'GlassCard' });
</script>
<template>
<div class="glass-card" :class="{ 'is-pressable': pressable }">
<slot></slot>
</div>
</template>
<style scoped>
.glass-card {
position: relative;
border-radius: 18px;
/* 渐变半透明底:上亮下沉,模拟玻璃厚度的光衰减 */
background: linear-gradient(
150deg,
hsl(var(--card, var(--background)) / 0.78),
hsl(var(--card, var(--background)) / 0.52)
);
backdrop-filter: blur(22px) saturate(170%);
-webkit-backdrop-filter: blur(22px) saturate(170%);
border: 1px solid hsl(var(--border) / 0.6);
/* 外投影给悬浮感inset 顶部白线是液态玻璃的高光边 */
box-shadow:
0 10px 34px hsl(222 47% 11% / 0.08),
inset 0 1px 0 hsl(0 0% 100% / 0.4);
transition:
transform 0.22s ease,
border-color 0.22s ease,
box-shadow 0.22s ease;
}
.glass-card.is-pressable {
cursor: pointer;
}
.glass-card.is-pressable:hover {
transform: translateY(-3px);
border-color: hsl(var(--primary) / 0.4);
box-shadow:
0 16px 44px hsl(222 47% 11% / 0.14),
inset 0 1px 0 hsl(0 0% 100% / 0.45);
}
/* 暗色:高光减弱、阴影加深(高光强度无法用主题变量表达,允许局部 .dark 覆盖) */
.dark .glass-card {
box-shadow:
0 10px 34px hsl(0 0% 0% / 0.35),
inset 0 1px 0 hsl(0 0% 100% / 0.09);
}
.dark .glass-card.is-pressable:hover {
box-shadow:
0 16px 44px hsl(0 0% 0% / 0.45),
inset 0 1px 0 hsl(0 0% 100% / 0.12);
}
/* 不支持 backdrop-filter 的环境退化为实底卡片,保证可读性 */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass-card {
background: hsl(var(--card, var(--background)));
}
}
</style>

View File

@@ -0,0 +1,72 @@
<script lang="ts" setup>
// 液态玻璃场景容器:氛围色斑背景 + 内容滚动区
// 毛玻璃卡片必须叠在「有颜色的背景」上才有液态透光感,这里统一铺氛围层,
// 业务页只管往默认插槽里放 GlassCard不要自己再造背景
defineOptions({ name: 'GlassScene' });
</script>
<template>
<div class="glass-scene">
<!-- 三个模糊色斑主色 / 品牌绿 / 冷蓝透出到卡片毛玻璃后形成"液态"流光 -->
<div class="glass-scene__blob blob-a"></div>
<div class="glass-scene__blob blob-b"></div>
<div class="glass-scene__blob blob-c"></div>
<div class="glass-scene__body">
<slot></slot>
</div>
</div>
</template>
<style scoped>
.glass-scene {
position: relative;
height: 100%;
overflow: hidden;
border-radius: 16px;
background: hsl(var(--background));
}
.glass-scene__blob {
position: absolute;
border-radius: 50%;
filter: blur(90px);
pointer-events: none;
}
.blob-a {
width: 460px;
height: 460px;
top: -140px;
left: -100px;
background: hsl(var(--primary) / 0.22);
}
.blob-b {
width: 380px;
height: 380px;
top: 30%;
right: -120px;
background: hsl(152 68% 45% / 0.16);
}
.blob-c {
width: 420px;
height: 420px;
bottom: -160px;
left: 32%;
background: hsl(212 90% 60% / 0.14);
}
/* 暗色下色斑降亮度,避免"脏"(透明度是质感参数,变量表达不了,允许局部 .dark 覆盖) */
.dark .blob-a {
background: hsl(var(--primary) / 0.14);
}
.dark .blob-b {
background: hsl(152 68% 40% / 0.1);
}
.dark .blob-c {
background: hsl(212 90% 55% / 0.1);
}
.glass-scene__body {
position: relative;
z-index: 1;
height: 100%;
overflow-y: auto;
padding: 16px;
}
</style>

View File

@@ -0,0 +1,6 @@
/**
* 液态毛玻璃组件Apple Liquid Glass 风格)
* GlassScene氛围色斑背景容器页面级GlassCard毛玻璃卡片须叠在 GlassScene 上)
*/
export { default as GlassCard } from './glass-card.vue';
export { default as GlassScene } from './glass-scene.vue';

View File

@@ -30,12 +30,11 @@ export const overridesPreferences = defineOverridesPreferences({
checkUpdatesInterval: 1,
enableRefreshToken: false,
},
// logo: {
// enable: true,
// source:
// // 'https://yanydy.oss-cn-hangzhou.aliyuncs.com/uploads/20241219/2b38e38414a2b9383b8643983e3f69d7.png',
// '/img/logo.png',
// },
logo: {
enable: true,
// 品牌 logo年糕猫+奶酪):源文件在 public/logo.png登录页/侧边栏/顶栏共用
source: '/logo.png',
},
sidebar: {
width: 220,
},

77
apps/web-antd/src/types/desktop.d.ts vendored Normal file
View File

@@ -0,0 +1,77 @@
/**
* Electron 桌面端注入的 window.desktopAPI 全局类型声明
* 实现见 apps/desktop/src/preload/index.tscontextBridge 注入),两边字段必须保持同步;
* 浏览器环境下该对象为 undefined——业务侧请统一走 #/util/desktop 的封装调用,
* 不要直接摸 window.desktopAPI封装里做了 no-op 降级)
*/
export {};
/** 更新流程事件checking / available / progress / downloaded / error 等) */
interface DesktopUpdateEvent {
payload?: any;
type: string;
}
/** 系统打印机信息Electron PrinterInfo 的宽松声明,够业务选打印机用) */
interface DesktopPrinter {
description?: string;
displayName: string;
isDefault?: boolean;
name: string;
[key: string]: any;
}
declare global {
interface Window {
desktopAPI?: {
/** 手动检查更新 */
checkUpdate: () => Promise<void>;
/** 手动控制任务栏闪烁notify 已内置,一般不用) */
flashFrame: (flag?: boolean) => Promise<void>;
/** 读取开机自启状态 */
getAutoLaunch: () => Promise<boolean>;
/** 枚举系统打印机 */
getPrinters: () => Promise<DesktopPrinter[]>;
/** 读桌面端本地设置(如默认打印机) */
getSetting: (key: string) => Promise<unknown>;
/** 应用版本号 */
getVersion: () => Promise<string>;
/** 恒为 true是否运行在 Electron 壳内的判断依据 */
isElectron: true;
/**
* 发系统通知route 为站内路由(如 /workspace时点击通知唤起窗口并跳转
* flash 默认 true窗口未聚焦时同时闪烁任务栏mac 为 Dock 弹跳)
*/
notify: (options: {
body?: string;
flash?: boolean;
route?: string;
silent?: boolean;
title: string;
}) => Promise<void>;
/** 订阅「通知点击要求跳转路由」事件,返回取消订阅函数 */
onNavigate: (callback: (route: string) => void) => () => void;
/** 订阅更新流程事件(展示下载进度等),返回取消订阅函数 */
onUpdateEvent: (
callback: (event: DesktopUpdateEvent) => void,
) => () => void;
/** 当前平台win32 / darwin / linux */
platform: string;
/** 打印完整 HTMLsilent 且有打印机名时静默打印,否则弹系统打印对话框 */
printHTML: (options: {
deviceName?: string;
html: string;
landscape?: boolean;
silent?: boolean;
}) => Promise<{ message?: string; success: boolean }>;
/** 更新下载完成后立即重启安装 */
quitAndInstall: () => Promise<void>;
/** 设置开机自启,返回设置后的实际状态 */
setAutoLaunch: (enabled: boolean) => Promise<boolean>;
/** 设置未读角标数0 清除) */
setBadge: (count: number) => Promise<void>;
/** 写桌面端本地设置 */
setSetting: (key: string, value: unknown) => Promise<void>;
};
}
}

View File

@@ -0,0 +1,95 @@
/**
* 桌面端Electron 壳)能力封装
*
* 业务侧统一从这里调用桌面能力,禁止直接摸 window.desktopAPI
* 浏览器环境下所有方法安全 no-op 降级(返回空值/false网页端行为完全不变。
* 类型声明见 #/types/desktop.d.ts与 apps/desktop 的 preload 保持同步)。
*
* 典型用法(后续接真实消息中心时):
* if (isElectron() && !document.hasFocus()) {
* desktop.notify({ title: '新消息', body: 'xxx', route: '/notice/list' });
* desktop.setBadge(unreadCount);
* }
*/
/** 提取 desktopAPI 的类型(浏览器环境为 undefined故取 NonNullable */
type DesktopAPI = NonNullable<Window['desktopAPI']>;
/** 是否运行在 Electron 桌面壳内web 侧做桌面端差异逻辑的唯一判断入口) */
export function isElectron(): boolean {
return typeof window !== 'undefined' && !!window.desktopAPI?.isElectron;
}
/** 取原始 desktopAPI仅本模块内部使用外部一律走下面的 desktop 封装) */
function api(): DesktopAPI | undefined {
return typeof window === 'undefined' ? undefined : window.desktopAPI;
}
/** 桌面端能力集合:每个方法在浏览器环境都是安全 no-op */
export const desktop = {
/** 手动检查更新(托盘菜单也有同款入口) */
async checkUpdate(): Promise<void> {
await api()?.checkUpdate();
},
/** 读取开机自启状态(浏览器环境恒为 false */
async getAutoLaunch(): Promise<boolean> {
return (await api()?.getAutoLaunch()) ?? false;
},
/** 枚举系统打印机(浏览器环境返回空数组) */
async getPrinters(): Promise<
Awaited<ReturnType<DesktopAPI['getPrinters']>>
> {
return (await api()?.getPrinters()) ?? [];
},
/** 读桌面端本地设置(如默认打印机名) */
async getSetting(key: string): Promise<unknown> {
return await api()?.getSetting(key);
},
/** 桌面端应用版本号(浏览器环境返回空串) */
async getVersion(): Promise<string> {
return (await api()?.getVersion()) ?? '';
},
/** 发系统通知:点击可携带站内路由,由 onNavigate 订阅方完成 router.push */
async notify(
options: Parameters<DesktopAPI['notify']>[0],
): Promise<void> {
await api()?.notify(options);
},
/** 订阅「通知点击跳转路由」事件;返回取消订阅函数(浏览器环境为空函数) */
onNavigate(callback: (route: string) => void): () => void {
return api()?.onNavigate(callback) ?? (() => {});
},
/** 订阅更新流程事件(下载进度展示等);返回取消订阅函数 */
onUpdateEvent(
callback: Parameters<DesktopAPI['onUpdateEvent']>[0],
): () => void {
return api()?.onUpdateEvent(callback) ?? (() => {});
},
/** 打印完整 HTML浏览器环境直接返回失败调用方自行走 iframe 打印兜底 */
async printHTML(
options: Parameters<DesktopAPI['printHTML']>[0],
): Promise<{ message?: string; success: boolean }> {
return (
(await api()?.printHTML(options)) ?? {
message: '当前环境不支持桌面打印',
success: false,
}
);
},
/** 更新下载完成后立即重启安装 */
async quitAndInstall(): Promise<void> {
await api()?.quitAndInstall();
},
/** 设置开机自启,返回设置后的实际状态 */
async setAutoLaunch(enabled: boolean): Promise<boolean> {
return (await api()?.setAutoLaunch(enabled)) ?? false;
},
/** 设置未读角标0 清除);登出/未读清零时记得调 setBadge(0) */
async setBadge(count: number): Promise<void> {
await api()?.setBadge(count);
},
/** 写桌面端本地设置 */
async setSetting(key: string, value: unknown): Promise<void> {
await api()?.setSetting(key, value);
},
};

View File

@@ -0,0 +1,61 @@
import { requestClient } from '#/api/request';
const prefix = 'wechat-account/';
/**
* 分页查询公众号账号列表(后端已脱敏:无 app_secret只有 has_secret 标记)
*/
export async function getWechatAccountList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 公众号账号下拉(发布弹窗选账号用,默认账号排最前)
*/
export async function getWechatAccountOption() {
return requestClient.get<any>(`${prefix}option`);
}
/**
* 公众号账号详情(同样脱敏)
*/
export async function getWechatAccountInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
* 新增公众号账号app_secret 后端 AES 加密入库)
*/
export async function createWechatAccount(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
/**
* 编辑公众号账号app_secret 传空 = 不修改密钥)
*/
export async function updateWechatAccount(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
/**
* 删除公众号账号
* @param data 形如 { ids: number[] }
*/
export async function deleteWechatAccount(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/**
* 设为默认发布账号(全局仅一个)
*/
export async function setDefaultWechatAccount(id: number) {
return requestClient.post<any>(`${prefix}set-default`, { id });
}
/**
* 测试连通性:后端用 appid+secret 直连微信换 token 验证
* 已保存账号只传 id表单未保存时可传 appid+app_secret
*/
export async function testWechatAccountConnection(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}test-connection`, data);
}

View File

@@ -0,0 +1,126 @@
<script lang="ts" setup>
// 公众号账号新增/编辑弹窗Secret 加密由后端处理,这里负责「编辑留空不改」交互与连通性测试
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createWechatAccount,
testWechatAccountConnection,
updateWechatAccount,
} from '../api';
import { modalFormProps } from '../config/form';
defineOptions({
name: 'WechatAccountFormModal',
});
const isUpdate = ref(false);
/** 保存成功后的父页面刷新回调(卡片页传入 onSaved */
const onSaved = ref<(() => void) | null>(null);
const testing = ref(false);
const [Form, formApi] = useVbenForm(modalFormProps);
/**
* 测试连通性:用表单当前 appid/secret 直连微信换 token
* 编辑态 secret 留空时后端自动用库里已保存的密文解密测试
*/
const testConnection = async () => {
const values = await formApi.getValues();
if (!values.appid) {
message.warning('请先填写 AppID');
return;
}
if (!isUpdate.value && !values.app_secret) {
message.warning('请先填写 AppSecret');
return;
}
testing.value = true;
testWechatAccountConnection({
id: values.id || 0,
appid: values.appid,
app_secret: values.app_secret || '',
})
.then(() => {
message.success('连接成功AppID/AppSecret 有效');
})
.finally(() => {
testing.value = false;
});
};
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
// 编辑态密钥留空 = 不修改,剔除空串避免后端误判
if (isUpdate.value && !values.app_secret) {
delete values.app_secret;
}
modalApi.lock();
const submitApi = isUpdate.value
? updateWechatAccount
: createWechatAccount;
submitApi(values)
.then(() => {
message.success('保存成功');
onSaved.value?.();
modalApi.close();
})
.finally(() => {
modalApi.unlock();
});
}
});
},
onOpenChange(isOpen: boolean) {
const data = (modalApi.getData() ?? {}) as Record<string, any>;
onSaved.value = isOpen ? (data.onSaved ?? null) : null;
if (isOpen) {
const { values, update } = data;
isUpdate.value = !!update;
formApi.resetForm();
if (values && update) {
// 密钥不回显后端也不会返回placeholder 提示「已配置」
formApi.setValues({ ...values, app_secret: '' });
}
// 按是否已配置密钥切换占位提示,避免用户误以为必须重填
formApi.updateSchema([
{
fieldName: 'app_secret',
componentProps: {
placeholder:
update && values?.has_secret
? '已配置,留空则不修改'
: '请输入 AppSecret',
allowClear: true,
autocomplete: 'new-password',
},
},
]);
}
},
});
</script>
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}公众号账号`"
class="w-[60%]"
>
<Form />
<template #prepend-footer>
<Button :loading="testing" @click="testConnection">测试连通</Button>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,13 @@
/**
* 公众号账号模块字典对齐后端status 1启用 0禁用与 AI/OSS 配置表同语义)
*/
export const WECHAT_ACCOUNT_STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];
/** 是否默认发布账号 */
export const WECHAT_IS_DEFAULT_OPTIONS = [
{ label: '默认账号', value: 1 },
{ label: '普通账号', value: 0 },
];

View File

@@ -0,0 +1,119 @@
import type { VbenFormProps } from '#/adapter/form';
import {
WECHAT_ACCOUNT_STATUS_OPTIONS,
WECHAT_IS_DEFAULT_OPTIONS,
} from './constants';
/**
* 公众号账号新增/编辑弹窗表单
* app_secret 特殊处理:新增必填;编辑留空表示不修改(后端只在非空时更新密钥)
*/
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入公众号名称',
},
fieldName: 'name',
label: '公众号名称',
rules: 'required',
formItemClass: 'col-span-6',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '公众号原始IDgh_ 开头,选填)',
},
fieldName: 'original_id',
label: '原始ID',
formItemClass: 'col-span-6',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入 AppIDwx 开头)',
},
fieldName: 'appid',
label: 'AppID',
rules: 'required',
formItemClass: 'col-span-6',
},
{
// 密钥用密码框防肩窥编辑时留空不改placeholder 由弹窗按 has_secret 动态更新
component: 'InputPassword',
componentProps: {
placeholder: '请输入 AppSecret',
allowClear: true,
autocomplete: 'new-password',
},
fieldName: 'app_secret',
label: 'AppSecret',
formItemClass: 'col-span-6',
dependencies: {
triggerFields: ['id'],
// 新增必填;编辑(有 id留空 = 不修改,去掉必填规则
rules: (values: Record<string, any>) => (values.id ? null : 'required'),
},
},
{
component: 'RadioGroup',
componentProps: {
options: WECHAT_IS_DEFAULT_OPTIONS,
},
defaultValue: 0,
fieldName: 'is_default',
label: '默认发布',
help: '发布图文时未指定账号则用默认账号,全局仅一个',
formItemClass: 'col-span-6',
},
{
component: 'RadioGroup',
componentProps: {
options: WECHAT_ACCOUNT_STATUS_OPTIONS,
},
defaultValue: 1,
fieldName: 'status',
label: '状态',
formItemClass: 'col-span-6',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '越大越靠前',
},
defaultValue: 0,
fieldName: 'sort',
label: '排序',
formItemClass: 'col-span-6',
},
{
component: 'Textarea',
componentProps: {
placeholder: '备注说明(选填)',
rows: 2,
},
fieldName: 'remark',
label: '备注',
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,439 @@
<script lang="ts" setup>
// 公众号账号管理液态毛玻璃卡片网格Apple Liquid Glass 风格)
// 每张卡片展示品牌头像 + 启用/默认标识 + AppID/原始ID/密钥配置情况 + 行内操作
// AppSecret 密文存库不回显,列表只给 has_secret是否已配置
import { onMounted, reactive, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import {
Button,
Empty,
Input,
message,
Modal,
Pagination,
Select,
Spin,
Tag,
Tooltip,
} from 'ant-design-vue';
import { GlassCard, GlassScene } from '#/components/glass';
import { Icon } from '#/components/icon';
import {
deleteWechatAccount,
getWechatAccountList,
setDefaultWechatAccount,
testWechatAccountConnection,
} from './api';
import AccountModal from './components/modal.vue';
import { WECHAT_ACCOUNT_STATUS_OPTIONS } from './config/constants';
defineOptions({
name: 'MediaWechatAccount',
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: AccountModal,
});
const loading = ref(false);
const list = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(12);
/** 筛选条件:名称/AppID 模糊、状态精确(对齐后端 queryField */
const filters = reactive({
name: '',
appid: '',
status: undefined as number | undefined,
});
/** 正在测试连接的账号 id按钮级 loading */
const testingId = ref(0);
/**
* 拉取账号列表(卡片分页数据源)
*/
const fetchList = async () => {
loading.value = true;
try {
const res = await getWechatAccountList({
page: page.value,
pageSize: pageSize.value,
name: filters.name || undefined,
appid: filters.appid || undefined,
status: filters.status,
});
list.value = res?.items ?? [];
total.value = Number(res?.total ?? 0);
} finally {
loading.value = false;
}
};
/** 查询(回到第一页) */
const handleSearch = () => {
page.value = 1;
fetchList();
};
/** 重置筛选 */
const handleReset = () => {
filters.name = '';
filters.appid = '';
filters.status = undefined;
handleSearch();
};
/** 分页切换 */
const handlePageChange = (p: number, size: number) => {
page.value = p;
pageSize.value = size;
fetchList();
};
/**
* 打开新增/编辑弹窗;保存成功后通过 onSaved 回调刷新卡片
*/
const openFormModal = (update: boolean, row?: any) => {
formModalApi
.setData({
update,
values: update ? row : null,
onSaved: fetchList,
})
.open();
};
/**
* 删除账号:危险操作先确认,删除后刷新列表
*/
const handleDelete = (row: any) => {
Modal.confirm({
title: '删除确认',
content: `确定要删除账号「${row.name}」吗?关联的图文不会被删除。`,
okType: 'danger',
onOk: async () => {
await deleteWechatAccount({ ids: [row.id] });
message.success('删除成功');
fetchList();
},
});
};
/**
* 设为默认账号:图文发布时未指定账号则用默认账号
*/
const handleSetDefault = async (row: any) => {
await setDefaultWechatAccount(row.id);
message.success(`已将「${row.name}」设为默认账号`);
fetchList();
};
/**
* 测试连接:后端用该账号的 secret 换 token 验证配置是否正确
*/
const handleTestConnection = (row: any) => {
testingId.value = row.id;
testWechatAccountConnection({ id: row.id })
.then(() => {
message.success(`${row.name}」连接测试通过`);
})
.finally(() => {
testingId.value = 0;
});
};
onMounted(fetchList);
</script>
<template>
<Page auto-content-height>
<FormModal />
<GlassScene>
<!-- 顶部工具条标题 + 筛选 + 新增 -->
<GlassCard class="toolbar">
<div class="toolbar-title">
<div class="toolbar-name">公众号账号</div>
<div class="toolbar-desc">AppSecret AES 加密存储仅用于服务端换取接口凭证</div>
</div>
<div class="toolbar-filters">
<Input
v-model:value="filters.name"
allow-clear
class="filter-item"
placeholder="账号名称"
@press-enter="handleSearch"
/>
<Input
v-model:value="filters.appid"
allow-clear
class="filter-item"
placeholder="AppID"
@press-enter="handleSearch"
/>
<Select
v-model:value="filters.status"
allow-clear
class="filter-item filter-status"
:options="WECHAT_ACCOUNT_STATUS_OPTIONS"
placeholder="状态"
/>
<Button @click="handleSearch">
<Icon class="mr-1" icon="ant-design:search-outlined" />
查询
</Button>
<Button @click="handleReset">重置</Button>
</div>
<Button type="primary" @click="openFormModal(false)">
<Icon class="mr-1" icon="ant-design:plus-outlined" />
新增账号
</Button>
</GlassCard>
<Spin :spinning="loading">
<!-- 卡片网格 -->
<div v-if="list.length > 0" class="card-grid">
<GlassCard v-for="row in list" :key="row.id" class="account-card">
<div class="account-head">
<!-- 品牌头像微信绿渐变 + 图标禁用账号做灰度 -->
<div class="account-avatar" :class="{ 'is-disabled': row.status !== 1 }">
<Icon icon="mdi:wechat" />
</div>
<div class="account-title">
<div class="account-name" :title="row.name">{{ row.name }}</div>
<div class="account-tags">
<Tag v-if="row.is_default === 1" color="gold">默认</Tag>
<Tag :color="row.status === 1 ? 'success' : 'default'">
{{ row.status === 1 ? '启用' : '禁用' }}
</Tag>
</div>
</div>
</div>
<div class="account-rows">
<div class="account-row">
<span class="row-label">AppID</span>
<span class="row-value mono" :title="row.appid">{{ row.appid }}</span>
</div>
<div class="account-row">
<span class="row-label">原始ID</span>
<span class="row-value mono">{{ row.original_id || '-' }}</span>
</div>
<div class="account-row">
<span class="row-label">AppSecret</span>
<span class="row-value">
<Tag :color="row.has_secret ? 'success' : 'warning'">
{{ row.has_secret ? '已配置' : '未配置' }}
</Tag>
</span>
</div>
<div class="account-row">
<span class="row-label">备注</span>
<span class="row-value" :title="row.remark">{{ row.remark || '-' }}</span>
</div>
</div>
<div class="account-foot">
<span class="account-time">{{ row.created_at }}</span>
<div class="account-actions">
<Button size="small" type="text" @click="openFormModal(true, row)">
<Icon class="mr-1" icon="uil:edit" />
编辑
</Button>
<Button
size="small"
type="text"
:loading="testingId === row.id"
@click="handleTestConnection(row)"
>
<Icon class="mr-1" icon="ant-design:api-outlined" />
测试
</Button>
<Tooltip v-if="row.is_default !== 1" title="发布图文时未指定账号则使用默认账号">
<Button size="small" type="text" @click="handleSetDefault(row)">
<Icon class="mr-1" icon="ant-design:star-outlined" />
设默认
</Button>
</Tooltip>
<Button danger size="small" type="text" @click="handleDelete(row)">
<Icon class="mr-1" icon="ant-design:delete-outlined" />
删除
</Button>
</div>
</div>
</GlassCard>
</div>
<!-- 空态引导新增 -->
<GlassCard v-else-if="!loading" class="empty-card">
<Empty description="还没有配置公众号账号">
<Button type="primary" @click="openFormModal(false)">
<Icon class="mr-1" icon="ant-design:plus-outlined" />
新增账号
</Button>
</Empty>
</GlassCard>
</Spin>
<!-- 分页 -->
<div v-if="total > 0" class="pager">
<Pagination
:current="page"
:page-size="pageSize"
:page-size-options="['12', '24', '48']"
show-size-changer
:show-total="(t: number) => `${t} 个账号`"
:total="total"
@change="handlePageChange"
/>
</div>
</GlassScene>
</Page>
</template>
<style scoped>
/* ===== 顶部工具条 ===== */
.toolbar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 14px 18px;
margin-bottom: 16px;
}
.toolbar-title {
min-width: 200px;
}
.toolbar-name {
font-size: 16px;
font-weight: 600;
color: hsl(var(--foreground));
}
.toolbar-desc {
margin-top: 2px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.toolbar-filters {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
justify-content: flex-end;
}
.filter-item {
width: 160px;
}
.filter-status {
width: 110px;
}
/* ===== 卡片网格 ===== */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.account-card {
display: flex;
flex-direction: column;
padding: 16px 18px;
}
.account-head {
display: flex;
align-items: center;
gap: 12px;
}
.account-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
flex-shrink: 0;
font-size: 26px;
color: #fff;
border-radius: 14px;
/* 微信品牌绿渐变(固定品牌色,暗色下同样成立) */
background: linear-gradient(145deg, #2fd571, #07c160);
box-shadow: 0 6px 16px rgb(7 193 96 / 30%);
}
.account-avatar.is-disabled {
filter: grayscale(0.9);
box-shadow: none;
}
.account-title {
min-width: 0;
flex: 1;
}
.account-name {
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-tags {
display: flex;
gap: 4px;
margin-top: 4px;
}
/* ===== 信息行 ===== */
.account-rows {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.account-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.row-label {
flex-shrink: 0;
width: 72px;
color: hsl(var(--muted-foreground));
}
.row-value {
flex: 1;
min-width: 0;
color: hsl(var(--foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-value.mono {
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 12px;
}
/* ===== 底部操作 ===== */
.account-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid hsl(var(--border) / 0.5);
}
.account-time {
font-size: 12px;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
.account-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
.empty-card {
padding: 48px 0;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,112 @@
import { requestClient } from '#/api/request';
const prefix = 'wechat-article/';
/**
* 分页查询图文列表(后端不返回 content_md 大字段,只有排版元信息)
*/
export async function getWechatArticleList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 图文详情(含 content_md 正文,编辑器加载用)
*/
export async function getWechatArticleInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
* 新增图文(后端自动写「创建」版本流水)
*/
export async function createWechatArticle(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
/**
* 更新图文后端自动写「修改」版本流水version_no+1
*/
export async function updateWechatArticle(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
/**
* 删除图文(软删,后端写「删除」版本流水留痕)
* @param data 形如 { ids: number[] }
*/
export async function deleteWechatArticle(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/**
* 某篇文章的版本流水(含操作人/动作/时间,倒序)
*/
export async function getWechatArticleVersionList(data: Record<string, any>) {
return requestClient.get<any>(`${prefix}version-list`, { params: data });
}
/**
* 版本快照详情(含该版本的 content_md用于快照预览与回退确认
*/
export async function getWechatArticleVersionInfo(id: number) {
return requestClient.get<any>(`${prefix}version-detail`, {
params: { id },
});
}
/**
* 回退到指定版本快照覆盖回文章version_no+1记「回退」流水
*/
export async function rollbackWechatArticle(data: {
id: number;
version_id: number;
}) {
return requestClient.post<any>(`${prefix}rollback`, data);
}
/**
* 一键发布content_html 为前端主题引擎渲染好的内联样式 HTML
* account_id 可选,不传时用文章绑定账号,再兜底默认账号
*/
export async function publishWechatArticle(data: {
account_id?: number;
content_html: string;
id: number;
}) {
return requestClient.post<any>(`${prefix}publish`, data);
}
/**
* 查询发布审核状态freepublish 是异步审核,需轮询回填 article_url
*/
export async function getWechatArticlePublishStatus(id: number) {
return requestClient.get<any>(`${prefix}publish-status`, {
params: { id },
});
}
/** 自定义排版主题(我的模板)接口前缀 */
const themePrefix = 'wechat-theme/';
/**
* 自定义主题全量列表编辑器主题面板加载非分页palette/styles 已解码为结构)
*/
export async function getWechatThemeList() {
return requestClient.get<any>(`${themePrefix}list`);
}
/**
* 导入自定义主题(后端做白名单清洗后入库)
* @param data 形如 { name, palette: string[], styles: Record<元素, 内联CSS> }
*/
export async function createWechatTheme(data: Record<string, any>) {
return requestClient.post<any>(`${themePrefix}create`, data);
}
/**
* 删除自定义主题(批量软删)
* @param data 形如 { ids: number[] }
*/
export async function deleteWechatTheme(data: Record<string, any>) {
return requestClient.post<any>(`${themePrefix}delete`, data);
}

View File

@@ -0,0 +1,318 @@
<script lang="ts" setup>
// 一键发布弹窗:选择公众号账号 → 前端渲染内联 HTML 交后端发布 → 轮询审核状态回填
// freepublish 是异步审核接口,提交成功 ≠ 发布成功,所以弹窗里内置状态轮询
import { computed, onUnmounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Image, message, Select, Spin, Tag } from 'ant-design-vue';
import { getWechatAccountOption } from '../../wechat-account/api';
import {
getWechatArticleInfo,
getWechatArticlePublishStatus,
publishWechatArticle,
} from '../api';
import { ARTICLE_STATUS_COLOR } from '../config/constants';
import { renderArticleHtml } from '../editor/render/renderer';
import { getTheme } from '../editor/themes';
import { ensureCustomThemesLoaded } from '../editor/themes/loader';
defineOptions({
name: 'WechatArticlePublishModal',
});
/** 文章详情(含正文与主题信息) */
const article = ref<any>(null);
const loading = ref(false);
/** 可选账号列表与当前选中账号 */
const accountOptions = ref<{ label: string; value: number }[]>([]);
const accountId = ref<number>(0);
/** 发布阶段ready 待发布 / polling 审核中 / done 已发布 / failed 失败 */
const phase = ref<'done' | 'failed' | 'polling' | 'ready'>('ready');
const failReason = ref('');
const articleUrl = ref('');
/** 审核状态轮询定时器(关闭弹窗必须清掉,避免后台空转) */
let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollCount = 0;
const canPublish = computed(
() =>
phase.value === 'ready' &&
!!article.value &&
!!article.value.cover_url &&
accountId.value > 0,
);
/** 停止轮询(发布结束 / 弹窗关闭时调用) */
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};
/** 通知父页面发布状态有变化(刷新列表/编辑器) */
const notifyPublished = () => {
const data = (modalApi.getData() ?? {}) as Record<string, any>;
data.onPublished?.();
};
/**
* 轮询发布审核状态:每 4 秒查一次,最多 15 次(一分钟);
* 超时不算失败,提示用户稍后在列表里手动刷新状态
*/
const startPolling = () => {
stopPolling();
pollCount = 0;
pollTimer = setInterval(async () => {
pollCount += 1;
try {
const res = await getWechatArticlePublishStatus(article.value.id);
if (res.status === 2) {
phase.value = 'done';
articleUrl.value = res.article_url ?? '';
stopPolling();
notifyPublished();
} else if (res.status === 3) {
phase.value = 'failed';
// 详细失败原因在 fail_reason如「平台审核不通过」status_text 只是状态名
failReason.value = res.fail_reason || res.status_text || '发布失败';
stopPolling();
notifyPublished();
}
} catch {
// 单次查询失败不中断轮询(可能是瞬时网络抖动)
}
if (pollCount >= 15 && pollTimer) {
stopPolling();
message.info('审核仍在进行中,可稍后在列表「刷新发布状态」查看结果');
// 这里不直接 notifyPublishedclose 会触发 onOpenChange(false),由那里统一通知
modalApi.close();
}
}, 4000);
};
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
confirmText: '立即发布',
onCancel() {
stopPolling();
modalApi.close();
},
onConfirm: async () => {
// 已提交/已结束时点「立即发布」给出明确反馈,避免静默无响应
if (phase.value !== 'ready') {
message.info(
phase.value === 'polling'
? '已提交发布,正在审核中,请稍候…'
: '本次发布已结束,可关闭弹窗',
);
return;
}
if (!canPublish.value) {
if (!article.value?.cover_url) {
message.warning('请先在编辑器里设置封面图,发布需要上传封面素材');
} else if (accountId.value <= 0) {
message.warning('请选择发布账号');
}
return;
}
// 用文章存储的主题+颜色渲染最终 HTML保证发布内容与预览一致
// 先确保自定义主题已加载:从列表页直接发布时编辑器没挂载过,注册表可能还是空的
await ensureCustomThemesLoaded();
const theme = getTheme(article.value.theme_key ?? '');
const color = article.value.theme_color || theme.palette[0] || '#07c160';
const contentHtml = renderArticleHtml(
article.value.content_md ?? '',
theme,
color,
);
modalApi.lock();
try {
await publishWechatArticle({
id: article.value.id,
account_id: accountId.value,
content_html: contentHtml,
});
phase.value = 'polling';
startPolling();
} finally {
modalApi.unlock();
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
stopPolling();
// 审核中途关闭弹窗(含超时自动关闭):文章状态已变为「发布中」,
// 必须通知父页面刷新,否则列表/编辑器还停留在旧状态
if (phase.value === 'polling') {
notifyPublished();
}
return;
}
const data = (modalApi.getData() ?? {}) as Record<string, any>;
article.value = null;
phase.value = 'ready';
failReason.value = '';
articleUrl.value = '';
loading.value = true;
// 并行拉文章详情与账号下拉;默认账号优先级:文章绑定账号 > is_default 账号 > 第一个
Promise.all([
getWechatArticleInfo(data?.articleId ?? 0),
getWechatAccountOption(),
])
.then(([detail, accounts]: [any, any[]]) => {
article.value = detail;
accountOptions.value = (accounts ?? []).map((item: any) => ({
label: item.is_default === 1 ? `${item.name}(默认)` : item.name,
value: item.id,
}));
const ids = (accounts ?? []).map((item: any) => item.id);
accountId.value = ids.includes(detail?.account_id)
? detail.account_id
: ((accounts ?? []).find((item: any) => item.is_default === 1)?.id ??
ids[0] ??
0);
})
.finally(() => {
loading.value = false;
});
},
});
onUnmounted(stopPolling);
</script>
<template>
<Modal class="w-[560px]" title="发布到公众号">
<Spin :spinning="loading">
<div v-if="article" class="publish-body">
<!-- 文章概要标题/摘要/封面确认发的是对的内容 -->
<div class="summary">
<Image
v-if="article.cover_url"
:src="article.cover_url"
:width="96"
:height="72"
style="border-radius: 6px; object-fit: cover"
/>
<div class="summary-text">
<div class="summary-title">{{ article.title }}</div>
<div class="summary-digest">
{{ article.digest || '(未填写摘要,发布时自动截取正文开头)' }}
</div>
<div class="summary-meta">
<Tag :color="ARTICLE_STATUS_COLOR[article.status] ?? 'default'">
{{ article.status_text }}
</Tag>
<span class="version">v{{ article.version_no }}</span>
</div>
</div>
</div>
<Alert
v-if="!article.cover_url"
type="warning"
show-icon
message="缺少封面图"
description="公众号发布必须上传封面素材,请回到编辑器设置封面后再发布"
style="margin-bottom: 16px"
/>
<!-- 账号选择 -->
<div class="field-row">
<span class="field-label">发布账号</span>
<Select
v-model:value="accountId"
:options="accountOptions"
:disabled="phase !== 'ready'"
placeholder="选择公众号账号"
style="flex: 1"
/>
</div>
<!-- 发布阶段反馈 -->
<Alert
v-if="phase === 'polling'"
type="info"
show-icon
message="已提交发布,微信审核中…"
description="freepublish 为异步审核,一般十几秒内出结果,正在自动查询"
style="margin-top: 16px"
/>
<Alert
v-if="phase === 'done'"
type="success"
show-icon
message="发布成功"
style="margin-top: 16px"
>
<template #description>
<a
v-if="articleUrl"
:href="articleUrl"
target="_blank"
rel="noopener"
>点击查看线上文章</a>
<span v-else>文章已发布可在列表中查看链接</span>
</template>
</Alert>
<Alert
v-if="phase === 'failed'"
type="error"
show-icon
message="发布失败"
:description="failReason"
style="margin-top: 16px"
/>
</div>
</Spin>
</Modal>
</template>
<style scoped>
.publish-body {
padding: 4px 0;
}
.summary {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.summary-text {
flex: 1;
min-width: 0;
}
.summary-title {
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-digest {
margin-top: 4px;
font-size: 12px;
color: hsl(var(--muted-foreground));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.summary-meta {
margin-top: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.version {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.field-row {
display: flex;
align-items: center;
gap: 12px;
}
.field-label {
flex-shrink: 0;
font-size: 14px;
color: hsl(var(--foreground));
}
</style>

View File

@@ -0,0 +1,261 @@
<script lang="ts" setup>
// 版本记录抽屉:左侧版本流水列表,右侧快照预览(按该版本的主题渲染),支持一键回退
// 回退成功后通过 setData 传入的 onRollback 回调通知父页面刷新(列表页刷表格 / 编辑器重载正文)
import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Empty, message, Modal, Spin, Tag } from 'ant-design-vue';
import {
getWechatArticleVersionInfo,
getWechatArticleVersionList,
rollbackWechatArticle,
} from '../api';
import { VERSION_ACTION_COLOR } from '../config/constants';
import { getTheme } from '../editor/themes';
import { ensureCustomThemesLoaded } from '../editor/themes/loader';
import { renderArticleHtml } from '../editor/render/renderer';
defineOptions({
name: 'WechatArticleVersionDrawer',
});
/** 当前文章 id打开时由 setData 传入) */
const articleId = ref(0);
const articleTitle = ref('');
/** 版本流水列表 */
const versions = ref<any[]>([]);
const listLoading = ref(false);
/** 当前选中的版本与其快照渲染结果 */
const activeVersionId = ref(0);
const detailLoading = ref(false);
const snapshotHtml = ref('');
const snapshotTitle = ref('');
const rollbacking = ref(false);
/**
* 拉取版本流水(版本记录一般不多,一次取 100 条足够)
*/
const loadVersions = async () => {
listLoading.value = true;
try {
const res = await getWechatArticleVersionList({
article_id: articleId.value,
page: 1,
pageSize: 100,
});
versions.value = res?.items ?? [];
// 默认选中最新一条,右侧直接出快照预览
if (versions.value.length > 0) {
selectVersion(versions.value[0]);
} else {
activeVersionId.value = 0;
snapshotHtml.value = '';
}
} finally {
listLoading.value = false;
}
};
/**
* 选中某个版本:拉快照详情并按该版本自己的主题+颜色渲染预览
* 为什么用版本自己的主题:回退会连排版一起回退,预览必须所见即所得
*/
const selectVersion = async (row: any) => {
activeVersionId.value = row.id;
detailLoading.value = true;
try {
const detail = await getWechatArticleVersionInfo(row.id);
snapshotTitle.value = detail?.title ?? '';
// 快照可能用了自定义主题:从列表页直接开抽屉时注册表可能还没加载
await ensureCustomThemesLoaded();
const theme = getTheme(detail?.theme_key ?? '');
const color = detail?.theme_color || theme.palette[0] || '#07c160';
snapshotHtml.value = renderArticleHtml(
detail?.content_md ?? '',
theme,
color,
);
} finally {
detailLoading.value = false;
}
};
/**
* 回退到选中版本:二次确认防误操作;成功后刷新流水并通知父页面
*/
const handleRollback = (row: any) => {
Modal.confirm({
title: '回退确认',
content: `确定回退到 v${row.version_no}${row.action_text}${row.created_at})吗?当前内容会先存为一条新版本,可再次回退找回`,
okText: '回退',
okType: 'danger',
onOk: async () => {
rollbacking.value = true;
try {
await rollbackWechatArticle({
id: articleId.value,
version_id: row.id,
});
message.success(`已回退到 v${row.version_no}`);
await loadVersions();
const data = (drawerApi.getData() ?? {}) as Record<string, any>;
data.onRollback?.();
} finally {
rollbacking.value = false;
}
},
});
};
const [Drawer, drawerApi] = useVbenDrawer({
// 版本抽屉是查看/回退操作,不需要默认「确定」按钮
footer: false,
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = (drawerApi.getData() ?? {}) as Record<string, any>;
articleId.value = data?.articleId ?? 0;
articleTitle.value = data?.articleTitle ?? '';
versions.value = [];
snapshotHtml.value = '';
loadVersions();
}
},
});
</script>
<template>
<Drawer class="w-[70%] min-w-[720px]" :title="`版本记录:${articleTitle}`">
<div class="version-layout">
<!-- 版本流水列表 -->
<div class="version-list">
<Spin :spinning="listLoading">
<Empty
v-if="!listLoading && versions.length === 0"
description="暂无版本记录"
/>
<div
v-for="row in versions"
:key="row.id"
class="version-item"
:class="{ active: row.id === activeVersionId }"
@click="selectVersion(row)"
>
<div class="version-item-head">
<span class="version-no">v{{ row.version_no }}</span>
<Tag :color="VERSION_ACTION_COLOR[row.action] ?? 'default'">
{{ row.action_text }}
</Tag>
</div>
<div class="version-item-title">{{ row.title }}</div>
<div class="version-item-meta">
<span>{{ row.operator_name || '未知操作人' }}</span>
<span>{{ row.created_at }}</span>
</div>
<div class="version-item-actions">
<a
class="rollback-link"
@click.stop="handleRollback(row)"
>回退到此版本</a>
</div>
</div>
</Spin>
</div>
<!-- 快照预览固定白底还原公众号阅读环境 -->
<div class="version-preview">
<Spin :spinning="detailLoading">
<div v-if="snapshotHtml" class="preview-paper">
<div class="preview-title">{{ snapshotTitle }}</div>
<div v-html="snapshotHtml"></div>
</div>
<Empty v-else description="点击左侧版本查看快照" />
</Spin>
</div>
</div>
</Drawer>
</template>
<style scoped>
.version-layout {
display: flex;
gap: 16px;
height: 100%;
min-height: 400px;
}
.version-list {
width: 280px;
flex-shrink: 0;
overflow-y: auto;
}
.version-item {
padding: 10px 12px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
margin-bottom: 10px;
cursor: pointer;
transition: border-color 0.2s;
background: hsl(var(--card, var(--background)));
}
.version-item:hover {
border-color: hsl(var(--primary) / 0.5);
}
.version-item.active {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.06);
}
.version-item-head {
display: flex;
align-items: center;
gap: 8px;
}
.version-no {
font-weight: 600;
color: hsl(var(--foreground));
}
.version-item-title {
margin-top: 6px;
font-size: 13px;
color: hsl(var(--foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-item-meta {
margin-top: 4px;
display: flex;
justify-content: space-between;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.version-item-actions {
margin-top: 6px;
text-align: right;
}
.rollback-link {
font-size: 12px;
color: hsl(var(--primary));
}
/* 快照预览固定白底:公众号正文就是白底,暗色模式下也不跟随主题反色 */
.version-preview {
flex: 1;
overflow-y: auto;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: #f5f5f5;
padding: 16px;
}
.preview-paper {
max-width: 420px;
margin: 0 auto;
background: #ffffff;
border-radius: 8px;
padding: 20px 18px;
box-shadow: 0 1px 6px rgb(0 0 0 / 8%);
}
.preview-title {
font-size: 20px;
font-weight: bold;
color: #1a1a1a;
line-height: 1.4;
margin-bottom: 14px;
}
</style>

View File

@@ -0,0 +1,26 @@
/**
* 图文模块字典:状态值对齐后端 WechatArticleStatusEnum0草稿 1发布中 2已发布 3发布失败
*/
export const ARTICLE_STATUS_OPTIONS = [
{ label: '草稿', value: 0 },
{ label: '发布中', value: 1 },
{ label: '已发布', value: 2 },
{ label: '发布失败', value: 3 },
];
/** 状态 → antd Tag 颜色(列表/抽屉/弹窗统一用这份映射) */
export const ARTICLE_STATUS_COLOR: Record<number, string> = {
0: 'default',
1: 'processing',
2: 'success',
3: 'error',
};
/** 版本动作 → Tag 颜色(对齐后端 WechatVersionActionEnum1创建2修改3删除4回退5发布 */
export const VERSION_ACTION_COLOR: Record<number, string> = {
1: 'success',
2: 'blue',
3: 'error',
4: 'warning',
5: 'purple',
};

View File

@@ -0,0 +1,380 @@
<script lang="ts" setup>
// 多设备预览:手机 / 平板 / 笔记本 / 电脑 四种壳,支持亮色/暗色阅读模拟
// 宽设备按预览栏实际宽度用 transform: scale 等比缩小;暗色由 dark.ts 做内联配色映射
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Segmented, Tooltip } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import { adaptHtmlForDarkMode } from '../render/dark';
defineOptions({
name: 'WechatDevicePreview',
});
const props = defineProps<{
author: string;
html: string;
title: string;
}>();
type DeviceKey = 'desktop' | 'laptop' | 'phone' | 'tablet';
/** 设备档案:逻辑宽高 + 正文列宽(微信 PC/平板阅读页正文列约 677px */
const DEVICES: Record<
DeviceKey,
{ article: number; height: number; name: string; width: number }
> = {
phone: { name: '手机', width: 375, height: 660, article: 375 },
tablet: { name: '平板', width: 768, height: 700, article: 677 },
laptop: { name: '笔记本', width: 1200, height: 720, article: 677 },
desktop: { name: '电脑', width: 1440, height: 780, article: 677 },
};
const device = ref<DeviceKey>('phone');
const isDark = ref(false);
const deviceOptions = Object.entries(DEVICES).map(([value, item]) => ({
label: item.name,
value,
}));
const current = computed(() => DEVICES[device.value]);
/** 笔记本/电脑用浏览器窗口壳,其余用移动设备壳 */
const isBrowser = computed(() => device.value === 'laptop' || device.value === 'desktop');
/** 暗色时对内联样式做配色映射(仅预览;发布仍是原配色,微信客户端自己转换) */
const renderedHtml = computed(() =>
isDark.value ? adaptHtmlForDarkMode(props.html) : props.html,
);
/** 预览页顶部展示的“发布日期”,仅用于视觉还原 */
const today = new Date();
const dateText = `${today.getFullYear()}${today.getMonth() + 1}${today.getDate()}`;
// ===== 按预览栏宽度等比缩放 =====
const stageRef = ref<HTMLElement | null>(null);
const stageWidth = ref(0);
let observer: null | ResizeObserver = null;
onMounted(() => {
observer = new ResizeObserver((entries) => {
stageWidth.value = entries[0]?.contentRect.width ?? 0;
});
if (stageRef.value) {
observer.observe(stageRef.value);
}
});
onBeforeUnmount(() => {
observer?.disconnect();
});
/** 缩放比:设备逻辑宽 > 可用宽时缩小,永不放大 */
const scale = computed(() => {
const available = stageWidth.value - 16;
if (available <= 0) return 1;
return Math.min(1, available / current.value.width);
});
</script>
<template>
<div class="preview-root">
<!-- 控制条设备切换 + 亮暗切换 -->
<div class="preview-bar">
<Segmented v-model:value="device" :options="deviceOptions" size="small" />
<Tooltip :title="isDark ? '暗色为模拟效果,实际以微信客户端为准' : '切换暗色阅读模拟'">
<button class="dark-toggle" :class="{ 'is-on': isDark }" type="button" @click="isDark = !isDark">
<Icon :icon="isDark ? 'ph:moon-fill' : 'ph:sun-bold'" />
{{ isDark ? '暗色' : '亮色' }}
</button>
</Tooltip>
</div>
<!-- 舞台设备壳按可用宽度等比缩放 -->
<div ref="stageRef" class="preview-stage">
<div
class="scale-box"
:style="{
width: `${current.width * scale}px`,
height: `${current.height * scale}px`,
}"
>
<div
class="device"
:style="{
width: `${current.width}px`,
height: `${current.height}px`,
transform: `scale(${scale})`,
}"
>
<!-- 移动设备壳手机/平板 -->
<div
v-if="!isBrowser"
class="mobile-shell"
:class="[device === 'phone' ? 'is-phone' : 'is-tablet', { 'is-dark': isDark }]"
>
<div class="mobile-top" :class="{ 'is-dark': isDark }">
<span v-if="device === 'phone'" class="phone-notch"></span>
<span v-else class="tablet-cam"></span>
</div>
<div class="screen" :class="{ 'is-dark': isDark }">
<div class="wx-article" :style="{ maxWidth: `${current.article}px` }">
<h1 class="wx-title" :class="{ 'is-dark': isDark }">
{{ title || '未命名文章' }}
</h1>
<div class="wx-meta">
<span class="wx-author">{{ author || '作者' }}</span>
<span class="wx-date" :class="{ 'is-dark': isDark }">{{ dateText }}</span>
</div>
<!-- 主题引擎渲染的内联样式 HTML输入源是 Markdownhtml:false XSS 风险 -->
<div class="wx-content" v-html="renderedHtml"></div>
<div class="wx-footer" :class="{ 'is-dark': isDark }">
预览效果实际以公众号编辑器为准
</div>
</div>
</div>
</div>
<!-- 浏览器窗口壳笔记本/电脑 -->
<div v-else class="browser-shell" :class="{ 'is-dark': isDark }">
<div class="chrome-bar" :class="{ 'is-dark': isDark }">
<span class="chrome-dot dot-red"></span>
<span class="chrome-dot dot-yellow"></span>
<span class="chrome-dot dot-green"></span>
<span class="chrome-address" :class="{ 'is-dark': isDark }">
mp.weixin.qq.com/s/preview
</span>
</div>
<div class="screen" :class="{ 'is-dark': isDark }">
<div class="wx-article is-wide" :style="{ maxWidth: `${current.article}px` }">
<h1 class="wx-title" :class="{ 'is-dark': isDark }">
{{ title || '未命名文章' }}
</h1>
<div class="wx-meta">
<span class="wx-author">{{ author || '作者' }}</span>
<span class="wx-date" :class="{ 'is-dark': isDark }">{{ dateText }}</span>
</div>
<div class="wx-content" v-html="renderedHtml"></div>
<div class="wx-footer" :class="{ 'is-dark': isDark }">
预览效果实际以公众号编辑器为准
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.preview-root {
display: flex;
flex-direction: column;
height: 100%;
}
.preview-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 12px 4px;
flex-shrink: 0;
flex-wrap: wrap;
}
.dark-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 10px;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: transparent;
color: hsl(var(--foreground));
font-size: 12px;
cursor: pointer;
}
.dark-toggle.is-on {
border-color: hsl(var(--primary));
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.08);
}
.preview-stage {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 8px 20px;
}
/* 缩放占位盒:尺寸 = 逻辑尺寸 × scale让滚动容器拿到正确布局尺寸 */
.scale-box {
margin: 0 auto;
position: relative;
}
.device {
transform-origin: top left;
}
/* ===== 移动设备壳 ===== */
.mobile-shell {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #ffffff;
border: 10px solid hsl(var(--border));
overflow: hidden;
box-shadow: 0 8px 32px rgb(0 0 0 / 12%);
}
.mobile-shell.is-phone {
border-radius: 36px;
}
.mobile-shell.is-tablet {
border-radius: 26px;
border-width: 14px;
}
.mobile-shell.is-dark {
background: #191919;
}
.mobile-top {
height: 24px;
background: #ffffff;
position: relative;
flex-shrink: 0;
}
.mobile-top.is-dark {
background: #191919;
}
.phone-notch {
position: absolute;
left: 50%;
top: 8px;
transform: translateX(-50%);
width: 120px;
height: 16px;
border-radius: 10px;
background: #1a1a1a;
}
.tablet-cam {
position: absolute;
left: 50%;
top: 9px;
transform: translateX(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: #1a1a1a;
}
/* ===== 浏览器窗口壳 ===== */
.browser-shell {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #ffffff;
border: 1px solid hsl(var(--border));
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgb(0 0 0 / 14%);
}
.browser-shell.is-dark {
background: #191919;
}
.chrome-bar {
display: flex;
align-items: center;
gap: 7px;
height: 38px;
padding: 0 14px;
background: #f3f4f6;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.chrome-bar.is-dark {
background: #26272b;
border-bottom-color: #333438;
}
.chrome-dot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.dot-red {
background: #ff5f57;
}
.dot-yellow {
background: #febc2e;
}
.dot-green {
background: #28c840;
}
.chrome-address {
flex: 1;
max-width: 420px;
margin-left: 10px;
height: 24px;
line-height: 24px;
padding: 0 12px;
border-radius: 12px;
background: #ffffff;
color: #9ca3af;
font-size: 12px;
overflow: hidden;
white-space: nowrap;
}
.chrome-address.is-dark {
background: #1b1c1f;
color: #6b7075;
}
/* ===== 阅读屏幕(亮/暗) ===== */
.screen {
flex: 1;
min-height: 0;
overflow-y: auto;
background: #ffffff;
}
.screen.is-dark {
background: #191919;
}
.wx-article {
padding: 20px 18px 32px;
margin: 0 auto;
}
.wx-article.is-wide {
padding: 28px 24px 40px;
}
.wx-title {
font-size: 22px;
font-weight: bold;
color: #1a1a1a;
line-height: 1.4;
margin: 0 0 10px;
}
.wx-title.is-dark {
color: #d5d5d5;
}
.wx-meta {
display: flex;
gap: 10px;
font-size: 13px;
margin-bottom: 20px;
}
.wx-author {
color: #576b95;
}
.wx-date {
color: #999999;
}
.wx-date.is-dark {
color: #6e6e6e;
}
.wx-content {
min-height: 200px;
}
.wx-footer {
margin-top: 32px;
text-align: center;
font-size: 12px;
color: #b2b2b2;
}
.wx-footer.is-dark {
color: #5a5a5a;
}
</style>

View File

@@ -0,0 +1,337 @@
<script lang="ts" setup>
// Markdown 工具条:常用语法一键插入 + 正文插图上传 + 相册多图上传
// 插入逻辑在父组件(编辑器持有 textarea 光标),这里只发命令事件
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Tooltip, Upload } from 'ant-design-vue';
import { uploadFile } from '#/api/core/upload';
import { Icon } from '#/components/icon';
defineOptions({
name: 'WechatEditorToolbar',
});
const emit = defineEmits<{
/** 语法命令:由父组件按光标位置插入对应 Markdown */
command: [cmd: string];
/** 相册确认:父组件插入连续图片行,渲染端自动拼相册 */
insertAlbum: [urls: string[]];
/** 图片上传成功:父组件在光标处插入 ![](url) */
insertImage: [url: string];
}>();
/** 工具条按钮定义:图标 + 提示 + 命令 key */
const tools = [
{ cmd: 'h2', icon: 'ci:heading-h2', tip: '小节标题' },
{ cmd: 'h3', icon: 'ci:heading-h3', tip: '次级标题' },
{ cmd: 'bold', icon: 'ant-design:bold-outlined', tip: '加粗' },
{ cmd: 'italic', icon: 'ant-design:italic-outlined', tip: '斜体' },
{ cmd: 'quote', icon: 'tabler:blockquote', tip: '引用' },
{ cmd: 'ul', icon: 'ant-design:unordered-list-outlined', tip: '无序列表' },
{ cmd: 'ol', icon: 'ant-design:ordered-list-outlined', tip: '有序列表' },
{ cmd: 'link', icon: 'ant-design:link-outlined', tip: '链接' },
{ cmd: 'code', icon: 'ant-design:code-outlined', tip: '行内代码' },
{ cmd: 'codeblock', icon: 'tabler:code-dots', tip: '代码块' },
{ cmd: 'hr', icon: 'ant-design:minus-outlined', tip: '分割线' },
];
const uploading = ref(false);
/**
* 正文插图:走现有 upload/image 接口,成功后把 URL 交给父组件插入
* 注意:发布时后端会自动把外链图搬到微信图床,这里存 OSS 链接即可
*/
const customRequest = async (options: any) => {
const { file, onSuccess, onError } = options;
uploading.value = true;
try {
const res = await uploadFile({ file });
emit('insertImage', res.url);
message.success('图片已插入正文');
onSuccess(res);
} catch (error) {
message.error('图片上传失败');
onError(error);
} finally {
uploading.value = false;
}
};
// ===== 相册多图上传 =====
/** 相册图片上限:对齐公众号常见拼图习惯,太多会让拼图格子过小 */
const ALBUM_MAX = 9;
/** 相册待插入的图片 URL 列表(可排序/删除) */
const albumUrls = ref<string[]>([]);
const albumUploading = ref(false);
const [AlbumModal, albumModalApi] = useVbenModal({
title: '插入相册(多图智能拼接)',
draggable: true,
onConfirm: () => {
if (albumUrls.value.length < 2) {
message.warning('至少 2 张图片才能拼相册');
return;
}
emit('insertAlbum', [...albumUrls.value]);
albumModalApi.close();
},
});
/** 打开相册弹窗:每次都从空列表开始,避免上次残留 */
const openAlbumModal = () => {
albumUrls.value = [];
albumModalApi.open();
};
/** 相册多选上传:逐张上传成功后追加进列表,超限直接拒绝 */
const albumRequest = async (options: any) => {
const { file, onSuccess, onError } = options;
if (albumUrls.value.length >= ALBUM_MAX) {
message.warning(`最多 ${ALBUM_MAX}`);
onError(new Error('limit'));
return;
}
albumUploading.value = true;
try {
const res = await uploadFile({ file });
albumUrls.value.push(res.url);
onSuccess(res);
} catch (error) {
message.error('图片上传失败');
onError(error);
} finally {
albumUploading.value = false;
}
};
/** 删除一张 */
const removeAlbumImage = (index: number) => {
albumUrls.value.splice(index, 1);
};
/** 左移/右移调整拼图顺序direction 为 -1 左移、1 右移) */
const moveAlbumImage = (index: number, direction: number) => {
const target = index + direction;
if (target < 0 || target >= albumUrls.value.length) return;
const list = albumUrls.value;
[list[index], list[target]] = [list[target] as string, list[index] as string];
};
</script>
<template>
<div class="md-toolbar">
<Tooltip v-for="tool in tools" :key="tool.cmd" :title="tool.tip">
<button
class="tool-btn"
type="button"
@click="emit('command', tool.cmd)"
>
<Icon :icon="tool.icon" />
</button>
</Tooltip>
<Tooltip title="插入图片">
<Upload
:custom-request="customRequest"
:show-upload-list="false"
accept="image/*"
>
<button class="tool-btn" type="button" :disabled="uploading">
<Icon
:icon="
uploading
? 'ant-design:loading-outlined'
: 'ant-design:picture-outlined'
"
/>
</button>
</Upload>
</Tooltip>
<Tooltip title="插入相册(多图拼接)">
<button class="tool-btn" type="button" @click="openAlbumModal">
<Icon icon="ant-design:appstore-outlined" />
</button>
</Tooltip>
<!-- 相册上传弹窗多选上传 + 排序删除确认后按顺序插入连续图片行 -->
<AlbumModal class="w-[560px]">
<div class="album-body">
<div class="album-tip">
相邻的图片行会自动拼成相册2 张两列4 张两行6 3×29 3×3顺序即拼图顺序
</div>
<div class="album-grid">
<div
v-for="(url, index) in albumUrls"
:key="url + index"
class="album-cell"
>
<img :src="url" alt="相册图片" class="album-img" />
<div class="album-cell-bar">
<button
class="cell-btn"
type="button"
title="左移"
:disabled="index === 0"
@click="moveAlbumImage(index, -1)"
>
<Icon icon="ant-design:left-outlined" />
</button>
<button
class="cell-btn"
type="button"
title="删除"
@click="removeAlbumImage(index)"
>
<Icon icon="ant-design:delete-outlined" />
</button>
<button
class="cell-btn"
type="button"
title="右移"
:disabled="index === albumUrls.length - 1"
@click="moveAlbumImage(index, 1)"
>
<Icon icon="ant-design:right-outlined" />
</button>
</div>
<span class="album-order">{{ index + 1 }}</span>
</div>
<Upload
v-if="albumUrls.length < ALBUM_MAX"
:custom-request="albumRequest"
:show-upload-list="false"
accept="image/*"
multiple
>
<div class="album-add">
<Icon
:icon="
albumUploading
? 'ant-design:loading-outlined'
: 'ant-design:plus-outlined'
"
/>
<span>{{ albumUrls.length }}/{{ ALBUM_MAX }}</span>
</div>
</Upload>
</div>
</div>
</AlbumModal>
</div>
</template>
<style scoped>
.md-toolbar {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
padding: 6px 8px;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.25);
}
.tool-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 6px;
background: transparent;
color: hsl(var(--foreground));
cursor: pointer;
font-size: 16px;
}
.tool-btn:hover {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
}
.tool-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.album-body {
padding: 4px 2px;
}
.album-tip {
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-bottom: 12px;
}
.album-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.album-cell {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid hsl(var(--border));
aspect-ratio: 1 / 1;
}
.album-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.album-cell-bar {
position: absolute;
inset-inline: 0;
bottom: 0;
display: flex;
justify-content: space-between;
padding: 3px 4px;
background: rgb(0 0 0 / 45%);
}
.cell-btn {
border: none;
background: transparent;
color: #ffffff;
cursor: pointer;
font-size: 12px;
display: inline-flex;
align-items: center;
padding: 2px;
}
.cell-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.album-order {
position: absolute;
top: 4px;
left: 4px;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
background: rgb(0 0 0 / 55%);
color: #ffffff;
font-size: 11px;
}
.album-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
aspect-ratio: 1 / 1;
min-width: 100px;
border: 1px dashed hsl(var(--border));
border-radius: 8px;
color: hsl(var(--muted-foreground));
cursor: pointer;
font-size: 18px;
}
.album-add:hover {
border-color: hsl(var(--primary));
color: hsl(var(--primary));
}
.album-add span {
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,631 @@
<script lang="ts" setup>
// 主题面板:内置 12 组 × 5 个共 60 套主题 + 「我的」自定义模板,一行两个卡片,缩略图整段真实渲染
// 分类改用横向滚动 chip 条选中的分类自动滚动居中12+ 个分类时不居中很难找到当前位置)
// 主题/颜色用 defineModel 双向绑定,父组件(编辑器)据此实时重渲染预览
import type { WechatTheme } from '../themes';
import { computed, nextTick, onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message, Popconfirm } from 'ant-design-vue';
import { deleteWechatTheme } from '../../api';
import { renderArticleHtml } from '../render/renderer';
import {
ALL_THEMES,
CUSTOM_CATEGORY_KEY,
customIdFromKey,
customThemeList,
DEFAULT_THEME_KEY,
getTheme,
isCustomThemeKey,
THEME_CATEGORIES,
} from '../themes';
import { ensureCustomThemesLoaded } from '../themes/loader';
import {
STARTER_TEMPLATE,
TEMPLATE_GUIDE_MD,
} from '../themes/template-guide';
import ThemeGuideModal from './theme-guide-modal.vue';
import ThemeImportModal from './theme-import-modal.vue';
defineOptions({
name: 'WechatThemeGallery',
});
/** 当前主题 keyv-model:theme-key */
const themeKey = defineModel<string>('themeKey', { required: true });
/** 当前主色v-model:color */
const color = defineModel<string>('color', { required: true });
/** 分类 chip 列表:内置分类 + 末尾追加「我的」(自定义模板伪分类) */
const categoryChips = [
...THEME_CATEGORIES,
{ key: CUSTOM_CATEGORY_KEY, name: '我的' },
];
/** 当前分类;默认定位到选中主题所在分类,找不到则第一个分类 */
const activeCategory = ref<string>(
getTheme(themeKey.value)?.category ?? THEME_CATEGORIES[0]?.key ?? 'minimal',
);
/** 当前选中主题对象(色板区展示它的预设色) */
const currentTheme = computed(() => getTheme(themeKey.value));
/** 当前分类下的主题「我的」读自定义注册表reactive加载完自动出现其余按分类过滤 */
const categoryThemes = computed(() =>
activeCategory.value === CUSTOM_CATEGORY_KEY
? customThemeList()
: ALL_THEMES.filter((theme) => theme.category === activeCategory.value),
);
/** 分类 chip 的 DOM 引用表:选中后 scrollIntoView 居中用 */
const chipRefs = new Map<string, HTMLElement>();
const setChipRef = (key: string, el: unknown) => {
if (el instanceof HTMLElement) {
chipRefs.set(key, el);
} else {
chipRefs.delete(key);
}
};
/**
* 把指定分类 chip 滚动到条内居中inline: 'center' 只滚横向容器,
* block: 'nearest' 避免把整个编辑器页面拽着竖向滚动
*/
const centerChip = (key: string, smooth = true) => {
chipRefs.get(key)?.scrollIntoView({
behavior: smooth ? 'smooth' : 'auto',
block: 'nearest',
inline: 'center',
});
};
/** 分类展开面板开关true 时单行 chip 条切换为多行平铺,方便一眼选到目标分类 */
const categoryExpanded = ref(false);
/**
* 切换分类:更新选中态并把该 chip 滚到居中;
* 从展开面板里选择时自动收回面板单行条重新挂载后再居中refs 要等 DOM 出来)
*/
const selectCategory = (key: string) => {
activeCategory.value = key;
if (categoryExpanded.value) {
categoryExpanded.value = false;
nextTick(() => centerChip(key, false));
} else {
centerChip(key);
}
};
/**
* 缩略图示例 Markdown标题 + 正文重点 + 引用 + 分割线 + 列表
* 覆盖主题个性最强的五类元素(容器底、标题、重点词、引用、分割线),差异一眼可见
*/
const SAMPLE_MD =
'## 排版标题\n\n正文示例看看字体、行距与 **重点词** 的样子。\n\n> 引用一句话,看看引用块的气质\n\n---\n\n- 列表项的样子';
/**
* 渲染主题缩略图 HTML选中主题用当前主色换色实时反映未选中用各自默认主色
* 每分类最多几十张卡、示例文本很短,实时渲染成本可忽略
*/
const sampleHtml = (theme: WechatTheme) =>
renderArticleHtml(
SAMPLE_MD,
theme,
theme.key === themeKey.value ? color.value : (theme.palette[0] ?? '#07c160'),
);
/**
* 切换主题:颜色重置为新主题的默认主色
* 为什么重置:每套主题的色板是按风格调过的,沿用旧色容易脏(如国风红配科技紫)
*/
const selectTheme = (key: string) => {
themeKey.value = key;
const theme = getTheme(key);
color.value = theme.palette[0] ?? '#07c160';
};
/** 点选预设色 */
const selectColor = (value: string) => {
color.value = value;
};
/** 自定义取色(原生 input[type=color],全端可用,零依赖) */
const onCustomColor = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target?.value) {
color.value = target.value;
}
};
/** 导入模板弹窗connectedComponent 连接,成功后自动切到「我的」并选中新模板) */
const [ImportModal, importModalApi] = useVbenModal({
connectedComponent: ThemeImportModal,
});
const openImport = () => {
importModalApi.setData({
onSaved: (key: string) => {
activeCategory.value = CUSTOM_CATEGORY_KEY;
categoryExpanded.value = false;
nextTick(() => centerChip(CUSTOM_CATEGORY_KEY));
// 注册表已在弹窗内强制刷新,能查到就直接选中让用户立刻看到效果
if (key && getTheme(key).key === key) {
selectTheme(key);
}
},
});
importModalApi.open();
};
/** 触发浏览器下载(导出模板包用) */
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
};
/**
* 导出模板包zip一次给全自定义模板需要的三样东西
* 1. 当前主题的完整样式 JSON改造起点含当前主色下的整套 CSS
* 2. 空白样板 starter.json「模板的模板」覆盖全部元素键 + {{color}} 占位示范)
* 3. 制作说明 md与「怎么制作」弹窗同源
* jszip 动态 import只有点导出才加载不拖累编辑器首屏
*/
const exporting = ref(false);
const exportTemplateZip = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
try {
const { default: JSZip } = await import('jszip');
const theme = currentTheme.value;
const payload = {
name: `${theme.name}-副本`,
palette: [...theme.palette],
styles: theme.styles(color.value),
};
const zip = new JSZip();
zip.file(`${theme.name}-模板.json`, JSON.stringify(payload, null, 2));
zip.file(
'模板样板-starter.json',
JSON.stringify(STARTER_TEMPLATE, null, 2),
);
zip.file('模板制作说明.md', TEMPLATE_GUIDE_MD);
const blob = await zip.generateAsync({ type: 'blob' });
downloadBlob(blob, `${theme.name}-模板包.zip`);
message.success(
`已导出「${theme.name}」模板包(含空白样板与制作说明),改完 JSON 从「我的」导入`,
);
} finally {
exporting.value = false;
}
};
/** 制作教程弹窗(与 zip 内说明 md 同源) */
const [GuideModal, guideModalApi] = useVbenModal({
connectedComponent: ThemeGuideModal,
});
const openGuide = () => {
guideModalApi.open();
};
/** 删除自定义模板:软删后强刷注册表;删的是正在用的主题时回退默认主题 */
const removing = ref(false);
const removeCustomTheme = async (theme: WechatTheme) => {
const id = customIdFromKey(theme.key);
if (id <= 0 || removing.value) {
return;
}
removing.value = true;
try {
await deleteWechatTheme({ ids: [id] });
await ensureCustomThemesLoaded(true);
if (themeKey.value === theme.key) {
selectTheme(DEFAULT_THEME_KEY);
}
message.success('模板已删除');
} finally {
removing.value = false;
}
};
onMounted(async () => {
// 拉自定义模板列表(全局去重,多面板同时挂载只发一次请求)
await ensureCustomThemesLoaded();
// 文章用的是自定义主题时把分类定位到「我的」setup 时注册表还没加载,那会儿定位不到)
if (
isCustomThemeKey(themeKey.value) &&
getTheme(themeKey.value).key === themeKey.value
) {
activeCategory.value = CUSTOM_CATEGORY_KEY;
}
// 初始就把当前分类 chip 滚到居中(无动画,避免打开面板时晃动)
await nextTick();
centerChip(activeCategory.value, false);
});
</script>
<template>
<div class="theme-panel">
<!-- 颜色区当前主题的预设色板 + 自定义取色 -->
<div class="color-section">
<div class="section-title">主题色</div>
<div class="palette-row">
<button
v-for="preset in currentTheme.palette"
:key="preset"
class="palette-dot"
:class="{ active: preset === color }"
:style="{ background: preset }"
type="button"
:title="preset"
@click="selectColor(preset)"
></button>
<label class="custom-color" title="自定义颜色">
<input
class="custom-color-input"
type="color"
:value="color"
@input="onCustomColor"
/>
<span class="custom-color-view" :style="{ background: color }"></span>
<span class="custom-color-plus">+</span>
</label>
</div>
</div>
<!-- 分类 chip 横向滚动 + 选中项自动居中右侧固定更多可展开多行平铺 -->
<div class="category-row">
<div v-if="!categoryExpanded" class="category-bar">
<button
v-for="category in categoryChips"
:key="category.key"
:ref="(el) => setChipRef(category.key, el)"
class="category-chip"
:class="{ active: activeCategory === category.key }"
type="button"
@click="selectCategory(category.key)"
>
{{ category.name }}
</button>
</div>
<div v-else class="category-panel-label">全部分类</div>
<button
class="category-more"
type="button"
@click="categoryExpanded = !categoryExpanded"
>
{{ categoryExpanded ? '收起 ▴' : '更多 ▾' }}
</button>
</div>
<!-- 展开面板全部分类多行平铺点选后自动收回 -->
<div v-if="categoryExpanded" class="category-panel">
<button
v-for="category in categoryChips"
:key="category.key"
class="category-chip"
:class="{ active: activeCategory === category.key }"
type="button"
@click="selectCategory(category.key)"
>
{{ category.name }}
</button>
</div>
<!-- 我的分类的操作区导入 / 导出模板包zip / 制作教程 -->
<div v-if="activeCategory === CUSTOM_CATEGORY_KEY" class="custom-actions">
<Button size="small" type="primary" @click="openImport">导入模板</Button>
<Button
:loading="exporting"
size="small"
title="下载 zip当前主题完整样式 + 空白样板 JSON + 制作说明 md"
@click="exportTemplateZip"
>
导出模板包
</Button>
<a class="guide-link" @click="openGuide">怎么制作</a>
</div>
<div
v-if="activeCategory === CUSTOM_CATEGORY_KEY && categoryThemes.length === 0"
class="custom-empty"
>
还没有自定义模板导出模板包拿到空白样板和制作说明
改完 JSON 导入模板不清楚格式先看怎么制作
</div>
<!-- 主题卡片一行两个的缩略图网格 -->
<div class="theme-list">
<div
v-for="theme in categoryThemes"
:key="theme.key"
class="theme-card"
:class="{ active: theme.key === themeKey }"
:title="theme.description"
@click="selectTheme(theme.key)"
>
<!-- 整段真实渲染缩略图2 倍宽画布缩到 0.5等效手机宽度的排版效果 -->
<div class="theme-sample">
<div class="theme-sample-inner" v-html="sampleHtml(theme)"></div>
</div>
<div class="theme-card-foot">
<span class="theme-name">{{ theme.name }}</span>
<span v-if="theme.key === themeKey" class="theme-check">已选</span>
<Popconfirm
v-if="isCustomThemeKey(theme.key)"
title="删除该模板?使用它的文章会回退默认排版"
@confirm="removeCustomTheme(theme)"
>
<button
class="theme-del"
title="删除模板"
type="button"
@click.stop
>
×
</button>
</Popconfirm>
</div>
</div>
</div>
<ImportModal />
<GuideModal />
</div>
</template>
<style scoped>
.theme-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.color-section {
padding: 12px 12px 4px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
}
.palette-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.palette-dot {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
outline: 1px solid hsl(var(--border));
}
.palette-dot.active {
border-color: hsl(var(--background));
outline: 2px solid hsl(var(--primary));
}
.custom-color {
position: relative;
width: 24px;
height: 24px;
cursor: pointer;
display: inline-flex;
}
.custom-color-input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.custom-color-view {
width: 24px;
height: 24px;
border-radius: 50%;
outline: 1px dashed hsl(var(--muted-foreground));
pointer-events: none;
}
.custom-color-plus {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 14px;
font-weight: bold;
text-shadow: 0 0 2px rgb(0 0 0 / 60%);
pointer-events: none;
}
/* 分类行:左侧滚动条区 + 右侧固定「更多」按钮 */
.category-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
}
/* 分类 chip 条:横向滚动 + 隐藏滚动条(选中项由 scrollIntoView 居中定位) */
.category-bar {
flex: 1;
min-width: 0;
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
}
.category-bar::-webkit-scrollbar {
display: none;
}
/* 展开态占位标题(替代单行条,避免与面板里的 chip 重复) */
.category-panel-label {
flex: 1;
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
/* 「更多/收起」按钮:钉在行尾不随 chip 滚动 */
.category-more {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
line-height: 20px;
border: 1px dashed hsl(var(--border));
background: hsl(var(--muted) / 0.4);
color: hsl(var(--muted-foreground));
cursor: pointer;
transition:
color 0.2s,
border-color 0.2s;
}
.category-more:hover {
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 0.5);
}
/* 展开面板:全部分类多行平铺 */
.category-panel {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px 8px;
}
.category-chip {
flex-shrink: 0;
padding: 4px 14px;
border-radius: 999px;
font-size: 12px;
line-height: 20px;
border: 1px solid hsl(var(--border));
background: transparent;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition:
color 0.2s,
background 0.2s,
border-color 0.2s;
}
.category-chip:hover {
color: hsl(var(--foreground));
border-color: hsl(var(--primary) / 0.5);
}
.category-chip.active {
background: hsl(var(--primary));
border-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
font-weight: 600;
}
/* 「我的」分类操作区与空态 */
.custom-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px 8px;
}
/* 制作教程入口:轻量文字链,与按钮区分层级 */
.guide-link {
margin-left: auto;
font-size: 12px;
color: hsl(var(--primary));
cursor: pointer;
}
.guide-link:hover {
text-decoration: underline;
}
.custom-empty {
margin: 4px 12px 8px;
padding: 14px 12px;
border: 1px dashed hsl(var(--border));
border-radius: 10px;
font-size: 12px;
line-height: 1.8;
color: hsl(var(--muted-foreground));
}
/* 一行两个的卡片网格 */
.theme-list {
flex: 1;
overflow-y: auto;
padding: 0 12px 16px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
align-content: start;
}
.theme-card {
border: 1px solid hsl(var(--border));
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition:
border-color 0.2s,
box-shadow 0.2s;
background: hsl(var(--card, var(--background)));
}
.theme-card:hover {
border-color: hsl(var(--primary) / 0.5);
box-shadow: 0 4px 14px hsl(var(--primary) / 0.12);
}
.theme-card.active {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
/* 缩略图窗口:固定高度裁切,白底承接主题(深色/纹理主题自带容器底会盖住白底) */
.theme-sample {
height: 168px;
background: #ffffff;
overflow: hidden;
position: relative;
}
/* 2 倍宽画布缩到 0.5:等效在 ~280px 宽的手机屏上渲染,字号比例真实 */
.theme-sample-inner {
width: 200%;
transform: scale(0.5);
transform-origin: top left;
padding: 8px 10px;
pointer-events: none;
}
.theme-card-foot {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-top: 1px solid hsl(var(--border) / 0.6);
}
.theme-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: hsl(var(--foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-check {
flex-shrink: 0;
font-size: 11px;
color: hsl(var(--primary));
font-weight: 600;
}
/* 自定义模板删除按钮小号幽灵态hover 变警示色 */
.theme-del {
flex-shrink: 0;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: hsl(var(--muted) / 0.6);
color: hsl(var(--muted-foreground));
font-size: 13px;
line-height: 1;
cursor: pointer;
padding: 0;
}
.theme-del:hover {
background: hsl(var(--destructive) / 0.12);
color: hsl(var(--destructive));
}
</style>

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
// 模板制作教程弹窗渲染与「导出模板包」zip 内 md 同源的指南文本
// 单一数据源在 themes/template-guide.ts改那里弹窗与 zip 说明同步更新
import { useVbenModal } from '@vben/common-ui';
import MarkdownIt from 'markdown-it';
import { TEMPLATE_GUIDE_MD } from '../themes/template-guide';
defineOptions({
name: 'WechatThemeGuideModal',
});
/** 教程是静态文本组件创建时渲染一次即可html:false 防注入,与正文渲染器同配置) */
const md = new MarkdownIt({ html: false, linkify: false, breaks: false });
const guideHtml = md.render(TEMPLATE_GUIDE_MD);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
showCancelButton: false,
confirmText: '我知道了',
onConfirm() {
modalApi.close();
},
});
</script>
<template>
<Modal class="w-[680px]" title="怎么制作自定义模板">
<div class="guide-body" v-html="guideHtml"></div>
</Modal>
</template>
<style scoped>
/* 教程正文排版:全部走主题变量,亮暗色自动适配 */
.guide-body {
font-size: 13px;
line-height: 1.9;
color: hsl(var(--foreground));
padding: 0 4px;
}
.guide-body :deep(h1) {
font-size: 17px;
font-weight: 700;
margin: 0 0 12px;
}
.guide-body :deep(h2) {
font-size: 14px;
font-weight: 700;
margin: 20px 0 8px;
padding-left: 8px;
border-left: 3px solid hsl(var(--primary));
}
.guide-body :deep(p) {
margin: 0 0 10px;
}
.guide-body :deep(ul),
.guide-body :deep(ol) {
margin: 0 0 10px;
padding-left: 20px;
}
.guide-body :deep(li) {
margin: 0 0 4px;
}
.guide-body :deep(code) {
font-size: 12px;
padding: 1px 5px;
border-radius: 4px;
color: hsl(var(--primary));
background: hsl(var(--muted) / 0.6);
}
.guide-body :deep(pre) {
margin: 0 0 10px;
padding: 10px 12px;
border-radius: 8px;
background: hsl(var(--muted) / 0.4);
border: 1px solid hsl(var(--border));
overflow-x: auto;
}
.guide-body :deep(pre code) {
color: hsl(var(--foreground));
background: transparent;
padding: 0;
}
.guide-body :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 0 0 12px;
font-size: 12px;
}
.guide-body :deep(th),
.guide-body :deep(td) {
border: 1px solid hsl(var(--border));
padding: 6px 10px;
text-align: left;
}
.guide-body :deep(th) {
background: hsl(var(--muted) / 0.4);
font-weight: 600;
}
.guide-body :deep(strong) {
color: hsl(var(--primary));
}
</style>

View File

@@ -0,0 +1,229 @@
<script lang="ts" setup>
// 导入自定义模板弹窗:粘贴/上传 JSON →(前端预检 + 后端白名单清洗)→ 入库 → 通知面板选中新模板
// JSON 协议:{ name, palette: HEX[], styles: { 元素: 内联CSS } },样式值支持 {{color}} 主色占位符
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message, Textarea } from 'ant-design-vue';
import { createWechatTheme } from '../../api';
import { CUSTOM_STYLE_KEYS } from '../themes/custom';
import { ensureCustomThemesLoaded } from '../themes/loader';
defineOptions({
name: 'WechatThemeImportModal',
});
/** 模板名称(留空时用 JSON 里的 name */
const name = ref('');
/** 模板 JSON 文本(粘贴或从 .json 文件读入) */
const jsonText = ref('');
/** 隐藏的文件选择器(原生 input避免为一个按钮引入 antd Upload 全家桶) */
const fileInputRef = ref<HTMLInputElement | null>(null);
/** 主色占位符字面量:模板里直接写 {{...}} 会被 Vue 当插值解析,必须从 script 注入 */
const COLOR_PLACEHOLDER = '{{color}}';
/** 示例 JSON一键填入用户改改就能用覆盖最常自定义的五类元素 */
const EXAMPLE_JSON = JSON.stringify(
{
name: '我的模板',
palette: ['#07c160', '#576b95', '#fa5151'],
styles: {
container:
'font-size: 15px; line-height: 1.9; color: #333333; word-break: break-word; text-align: left; padding: 20px 14px; background: #ffffff;',
h2: 'font-size: 17px; font-weight: bold; color: {{color}}; line-height: 1.5; margin: 24px 0 12px; padding-left: 10px; border-left: 4px solid {{color}};',
p: 'margin: 0 0 14px; font-size: 15px; line-height: 1.9; color: #333333;',
blockquote:
'margin: 16px 0; padding: 10px 14px; border-left: 3px solid {{color}}; background: rgba(0, 0, 0, 0.04); border-radius: 0 6px 6px 0;',
blockquoteP: 'margin: 0; font-size: 14px; line-height: 1.8; color: #666666;',
strong: 'font-weight: bold; color: {{color}};',
hr: 'border: none; border-top: 1px solid #e5e5e5; margin: 24px 0;',
},
},
null,
2,
);
/** 一键填入示例 JSON不覆盖用户已输入的内容防误触 */
const fillExample = () => {
if (jsonText.value.trim() !== '') {
message.info('输入框里已有内容,请先清空再填入示例');
return;
}
jsonText.value = EXAMPLE_JSON;
};
/** 触发文件选择 */
const pickFile = () => {
fileInputRef.value?.click();
};
/** 读取 .json 文件内容到文本框FileReader 纯前端读,不经服务器) */
const onFileChange = (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.addEventListener('load', () => {
jsonText.value = String(reader.result ?? '');
if (name.value === '') {
// 文件名(去扩展名)作为默认模板名,少填一步
name.value = file.name.replace(/\.json$/i, '').slice(0, 50);
}
});
reader.readAsText(file, 'utf-8');
// 清空 value同一个文件再次选择也能触发 change
input.value = '';
};
/**
* 前端预检:解析 JSON、兼容「完整对象」与「直接 styles 映射」两种粘贴习惯,
* 返回标准化 payload不合法时抛出中文提示
*/
const parsePayload = (): Record<string, any> => {
let parsed: any;
try {
parsed = JSON.parse(jsonText.value);
} catch {
throw new Error('JSON 解析失败,请检查格式(可点「填入示例」看标准结构)');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('JSON 需要是一个对象');
}
// 用户直接粘贴 styles 映射(顶层就是 container/h2…时自动包一层
const styles =
parsed.styles && typeof parsed.styles === 'object'
? parsed.styles
: CUSTOM_STYLE_KEYS.some((key) => typeof parsed[key] === 'string')
? parsed
: null;
if (!styles) {
throw new Error(
`styles 里没有可用的元素样式(支持键:${CUSTOM_STYLE_KEYS.join(' / ')}`,
);
}
const finalName = (name.value.trim() || String(parsed.name ?? '').trim()).slice(0, 50);
if (finalName === '') {
throw new Error('请填写模板名称(输入框或 JSON 里的 name 二选一)');
}
return {
name: finalName,
palette: Array.isArray(parsed.palette) ? parsed.palette : [],
styles,
};
};
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
confirmText: '导入模板',
onOpenChange(isOpen) {
if (isOpen) {
// 每次打开都从空白开始,避免上次残留内容误提交
name.value = '';
jsonText.value = '';
}
},
onConfirm: async () => {
let payload: Record<string, any>;
try {
payload = parsePayload();
} catch (error: any) {
message.warning(error?.message ?? 'JSON 校验失败');
return;
}
modalApi.lock();
try {
const res = await createWechatTheme(payload);
// 强制刷新注册表,让新模板立刻可选可渲染
await ensureCustomThemesLoaded(true);
message.success('模板导入成功');
const data = (modalApi.getData() ?? {}) as Record<string, any>;
data.onSaved?.(`custom-${res?.id ?? 0}`);
modalApi.close();
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[560px]" title="导入自定义模板">
<div class="import-body">
<div class="field-label">模板名称</div>
<Input
v-model:value="name"
:maxlength="50"
placeholder="不填则使用 JSON 里的 name"
/>
<div class="field-label">
<span>模板 JSON</span>
<span class="field-actions">
<a @click="fillExample">填入示例</a>
<a @click="pickFile">上传 .json 文件</a>
</span>
</div>
<Textarea
v-model:value="jsonText"
class="json-input"
:rows="12"
placeholder='{"name": "我的模板", "palette": ["#07c160"], "styles": {"h2": "...", "p": "..."}}'
/>
<input
ref="fileInputRef"
accept=".json,application/json"
class="hidden"
type="file"
@change="onFileChange"
/>
<div class="import-tips">
<p>· styles 元素 内联 CSS映射支持的元素键container / h1~h3 / p / blockquote(P) / ul / ol / li / strong / em / a / hr / img / code / pre(Code) / table / th / td</p>
<p>· 样式值里可写 <code>{{ COLOR_PLACEHOLDER }}</code> 占位符切换主题色时会实时替换</p>
<p>· 不想从零写导出模板包 zip当前主题完整样式 + 空白样板 + 制作说明 md改完 JSON 再导回来</p>
<p>· 后端会做白名单清洗去除引号/尖括号等危险字符仅保留合法内联样式</p>
</div>
</div>
</Modal>
</template>
<style scoped>
.import-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
margin-top: 4px;
}
.field-actions {
display: inline-flex;
gap: 12px;
font-weight: 400;
}
.json-input {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
}
.import-tips {
font-size: 12px;
line-height: 1.8;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.4);
border-radius: 8px;
padding: 8px 12px;
}
.import-tips p {
margin: 0;
}
.import-tips code {
color: hsl(var(--primary));
}
</style>

View File

@@ -0,0 +1,623 @@
<script lang="ts" setup>
// 图文全屏编辑器:左 Markdown 编辑|中 手机壳实时预览|右 40 套主题面板
// 保存走 create/update后端自动记版本流水发布/版本记录复用列表页的弹窗与抽屉
import { computed, onMounted, reactive, ref } from 'vue';
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import {
Button,
Dropdown,
Input,
Menu,
message,
Modal,
Select,
Tag,
Textarea,
} from 'ant-design-vue';
import UploadImage from '#/components/form/components/upload-image.vue';
import { Icon } from '#/components/icon';
import { getWechatAccountOption } from '../../wechat-account/api';
import {
createWechatArticle,
getWechatArticleInfo,
updateWechatArticle,
} from '../api';
import PublishModal from '../components/publish-modal.vue';
import VersionDrawer from '../components/version-drawer.vue';
import { ARTICLE_STATUS_COLOR } from '../config/constants';
import DevicePreview from './components/device-preview.vue';
import EditorToolbar from './components/editor-toolbar.vue';
import ThemeGallery from './components/theme-gallery.vue';
import { copyForPlatform, PLATFORMS } from './render/platforms';
import { renderArticleHtml } from './render/renderer';
import { DEFAULT_THEME_KEY, getTheme } from './themes';
import { ensureCustomThemesLoaded } from './themes/loader';
defineOptions({
name: 'MediaWechatArticleEdit',
});
const route = useRoute();
const router = useRouter();
/** 当前文章 id0 表示新建,保存成功后回填并 replace 路由 query */
const articleId = ref(Number(route.query.id ?? 0));
const loading = ref(false);
const saving = ref(false);
/** 文章状态(编辑已发布文章时头部提示) */
const status = ref(0);
const statusText = ref('');
const versionNo = ref(0);
/** 编辑表单:字段名与后端 snake_case 对齐 */
const form = reactive({
account_id: 0 as number,
title: '',
author: '',
digest: '',
cover_url: '',
content_md: '',
theme_key: DEFAULT_THEME_KEY,
theme_color: getTheme(DEFAULT_THEME_KEY).palette[0] ?? '#07c160',
});
/** 保存点快照:离开页面前比对是否有未保存修改 */
const savedSnapshot = ref('');
const snapshot = () => JSON.stringify(form);
const isDirty = computed(() => snapshot() !== savedSnapshot.value);
/** 实时预览 HTML正文 + 主题 + 主色,任意一个变都重渲染 */
const previewHtml = computed(() =>
renderArticleHtml(form.content_md, getTheme(form.theme_key), form.theme_color),
);
/** 发布账号下拉(设置抽屉里选) */
const accountOptions = ref<{ label: string; value: number }[]>([]);
/** 封面图与 UploadImage 组件的 string[] 协议互转 */
const coverList = computed({
get: () => (form.cover_url ? [form.cover_url] : []),
set: (urls: string[]) => {
form.cover_url = urls[0] ?? '';
},
});
const textareaRef = ref<HTMLTextAreaElement | null>(null);
/**
* 加载文章详情(编辑态);新建态初始化空白正文与默认主题
*/
const load = async () => {
if (articleId.value <= 0) {
savedSnapshot.value = snapshot();
return;
}
loading.value = true;
try {
const detail = await getWechatArticleInfo(articleId.value);
form.account_id = detail?.account_id ?? 0;
form.title = detail?.title ?? '';
form.author = detail?.author ?? '';
form.digest = detail?.digest ?? '';
form.cover_url = detail?.cover_url ?? '';
form.content_md = detail?.content_md ?? '';
form.theme_key = detail?.theme_key || DEFAULT_THEME_KEY;
form.theme_color =
detail?.theme_color || getTheme(form.theme_key).palette[0] || '#07c160';
status.value = detail?.status ?? 0;
statusText.value = detail?.status_text ?? '';
versionNo.value = detail?.version_no ?? 0;
savedSnapshot.value = snapshot();
} finally {
loading.value = false;
}
};
/**
* 保存:新建走 create拿 id 回填路由),编辑走 update后端自动写版本流水
* @param silent 静默保存(发布前自动保存时不弹「保存成功」)
*/
const save = async (silent = false): Promise<boolean> => {
if (!form.title.trim()) {
message.warning('请填写文章标题');
return false;
}
if (!form.content_md.trim()) {
message.warning('正文不能为空');
return false;
}
saving.value = true;
try {
if (articleId.value > 0) {
await updateWechatArticle({ id: articleId.value, ...form });
} else {
const res = await createWechatArticle({ ...form });
articleId.value = Number(res?.id ?? 0);
// 用 replace 而不是 push避免「返回」又回到空白新建页
router.replace({ query: { id: String(articleId.value) } });
}
savedSnapshot.value = snapshot();
versionNo.value += 1;
if (!silent) {
message.success('保存成功');
}
return true;
} finally {
saving.value = false;
}
};
// ========== 发布弹窗 / 版本抽屉(复用列表页组件) ==========
const [PublishModalComp, publishModalApi] = useVbenModal({
connectedComponent: PublishModal,
});
const [VersionDrawerComp, versionDrawerApi] = useVbenDrawer({
connectedComponent: VersionDrawer,
});
/** 发布:未保存的修改先静默保存,保证发布的是最新内容 */
const handlePublish = async () => {
if ((isDirty.value || articleId.value <= 0) && !(await save(true))) {
return;
}
publishModalApi
.setData({
articleId: articleId.value,
onPublished: () => load(),
})
.open();
};
/** 版本记录:回退成功后重载编辑器内容 */
const handleVersions = () => {
if (articleId.value <= 0) {
message.warning('请先保存文章');
return;
}
versionDrawerApi
.setData({
articleId: articleId.value,
articleTitle: form.title,
onRollback: () => load(),
})
.open();
};
/** 多平台复制:公众号 HTML / 知乎 HTML / 掘金 MD / 小红书文本 */
const handleCopy = async (info: { key: number | string }) => {
// antd Menu 的 key 类型是 string | number这里统一转字符串再匹配平台
const key = String(info.key);
const ok = await copyForPlatform(
key as any,
form.content_md,
getTheme(form.theme_key),
form.theme_color,
);
const platform = PLATFORMS.find((item) => item.key === key);
if (ok) {
message.success(`已复制${platform?.name}格式,直接去粘贴即可`);
} else {
message.error('复制失败,请检查浏览器剪贴板权限');
}
};
/** 返回列表 */
const goBack = () => {
router.push('/media/wechat-article');
};
// 未保存修改的离开拦截(关标签页/切菜单都会走路由离开)
onBeforeRouteLeave((_to, _from, next) => {
if (!isDirty.value) {
next();
return;
}
Modal.confirm({
title: '有未保存的修改',
content: '离开将丢失未保存的修改,确定离开吗?',
okText: '离开',
okType: 'danger',
cancelText: '留下',
onOk: () => next(),
onCancel: () => next(false),
});
});
// ========== 设置抽屉:作者/摘要/封面/默认账号 ==========
const [SettingsDrawer, settingsDrawerApi] = useVbenDrawer({
footer: false,
});
// ========== Markdown 工具条命令 ==========
/**
* 在光标处包裹/插入 Markdown 片段
* @param before 选区前插入的文本
* @param after 选区后插入的文本
* @param placeholder 无选区时的占位文字
*/
const wrapSelection = (before: string, after: string, placeholder: string) => {
const el = textareaRef.value;
if (!el) return;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const selected = form.content_md.slice(start, end) || placeholder;
form.content_md =
form.content_md.slice(0, start) +
before +
selected +
after +
form.content_md.slice(end);
// 恢复焦点并把光标定位到插入内容之后,连续操作不用重新点编辑区
requestAnimationFrame(() => {
el.focus();
const pos = start + before.length + selected.length + after.length;
el.setSelectionRange(pos, pos);
});
};
/** 行首插入前缀(标题/引用/列表这类行级语法) */
const prefixLine = (prefix: string, placeholder: string) => {
const el = textareaRef.value;
if (!el) return;
const start = el.selectionStart ?? 0;
// 找到光标所在行的行首
const lineStart = form.content_md.lastIndexOf('\n', start - 1) + 1;
const needsNewline =
lineStart !== start && form.content_md.slice(lineStart, start).trim() !== '';
const insert = needsNewline ? `\n\n${prefix}${placeholder}` : prefix;
const at = needsNewline ? start : lineStart;
form.content_md =
form.content_md.slice(0, at) + insert + form.content_md.slice(at);
requestAnimationFrame(() => {
el.focus();
const pos = at + insert.length + (needsNewline ? 0 : start - lineStart);
el.setSelectionRange(pos, pos);
});
};
/** 工具条命令分发 */
const applyCommand = (cmd: string) => {
switch (cmd) {
case 'bold': {
wrapSelection('**', '**', '加粗文字');
break;
}
case 'code': {
wrapSelection('`', '`', '代码');
break;
}
case 'codeblock': {
wrapSelection('\n\n```\n', '\n```\n\n', '代码内容');
break;
}
case 'h2': {
prefixLine('## ', '小节标题');
break;
}
case 'h3': {
prefixLine('### ', '次级标题');
break;
}
case 'hr': {
wrapSelection('\n\n---\n\n', '', '');
break;
}
case 'italic': {
wrapSelection('*', '*', '斜体文字');
break;
}
case 'link': {
wrapSelection('[', '](https://)', '链接文字');
break;
}
case 'ol': {
prefixLine('1. ', '列表项');
break;
}
case 'quote': {
prefixLine('> ', '引用内容');
break;
}
case 'ul': {
prefixLine('- ', '列表项');
break;
}
default: {
break;
}
}
};
/** 工具条插图回调:光标处插入图片语法 */
const insertImage = (url: string) => {
wrapSelection('\n\n![图片](', ')\n\n', url);
};
/**
* 工具条相册回调:按顺序插入连续图片行
* 渲染器会把相邻纯图片行自动分组拼成相册,无需额外语法
*/
const insertAlbum = (urls: string[]) => {
const lines = urls.map((url) => `![图片](${url})`).join('\n');
// 相册整体作为 before 插入:即使编辑器里有选区,选中内容也原样保留在相册之后
wrapSelection(`\n\n${lines}\n\n`, '', '');
};
/** 字数统计(不含空白,粗略值) */
const wordCount = computed(
() => form.content_md.replaceAll(/\s/g, '').length,
);
onMounted(() => {
// 自定义主题注册表(我的模板):文章用的是 custom-* 主题时,加载完预览会自动重渲染
ensureCustomThemesLoaded();
load();
// 账号下拉:默认账号排最前(后端已排序)
getWechatAccountOption().then((accounts: any[]) => {
accountOptions.value = (accounts ?? []).map((item: any) => ({
label: item.is_default === 1 ? `${item.name}(默认)` : item.name,
value: item.id,
}));
});
});
</script>
<template>
<div class="editor-root">
<PublishModalComp />
<VersionDrawerComp />
<!-- 顶部操作栏 -->
<div class="editor-header">
<Button type="text" @click="goBack">
<Icon icon="ant-design:arrow-left-outlined" />
返回
</Button>
<Input
v-model:value="form.title"
class="title-input"
placeholder="请输入文章标题…"
:bordered="false"
:maxlength="64"
/>
<div class="header-meta">
<Tag v-if="articleId > 0" :color="ARTICLE_STATUS_COLOR[status]">
{{ statusText }}
</Tag>
<span v-if="articleId > 0" class="meta-text">v{{ versionNo }}</span>
<span class="meta-text">{{ wordCount }} </span>
<span v-if="isDirty" class="meta-dirty">未保存</span>
</div>
<div class="header-actions">
<Button @click="settingsDrawerApi.open()">
<Icon icon="ant-design:setting-outlined" />
设置
</Button>
<Button @click="handleVersions">
<Icon icon="ant-design:history-outlined" />
版本
</Button>
<Dropdown>
<Button>
<Icon icon="ant-design:copy-outlined" />
复制格式
<Icon icon="ant-design:down-outlined" />
</Button>
<template #overlay>
<Menu @click="handleCopy">
<Menu.Item v-for="platform in PLATFORMS" :key="platform.key">
<div class="copy-item">
<span>{{ platform.name }}</span>
<span class="copy-tip">{{ platform.tip }}</span>
</div>
</Menu.Item>
</Menu>
</template>
</Dropdown>
<Button :loading="saving" @click="save(false)">
<Icon icon="ant-design:save-outlined" />
保存
</Button>
<Button type="primary" @click="handlePublish">
<Icon icon="ant-design:send-outlined" />
发布
</Button>
</div>
</div>
<!-- 三栏主体 -->
<div v-loading="loading" class="editor-main">
<!-- Markdown 编辑区 -->
<div class="pane pane-editor">
<EditorToolbar
@command="applyCommand"
@insert-image="insertImage"
@insert-album="insertAlbum"
/>
<textarea
ref="textareaRef"
v-model="form.content_md"
class="md-textarea"
placeholder="用 Markdown 书写正文,右侧实时预览排版效果…&#10;&#10;## 小节标题&#10;正文段落,支持 **加粗**、*斜体*、[链接](https://)、图片、引用、列表、代码块等语法"
spellcheck="false"
></textarea>
</div>
<!-- 多设备亮暗预览手机/平板/笔记本/电脑 -->
<div class="pane pane-preview">
<DevicePreview
:html="previewHtml"
:title="form.title"
:author="form.author"
/>
</div>
<!-- 主题面板 -->
<div class="pane pane-themes">
<ThemeGallery
v-model:theme-key="form.theme_key"
v-model:color="form.theme_color"
/>
</div>
</div>
<!-- 设置抽屉作者/摘要/封面/发布账号 -->
<SettingsDrawer class="w-[420px]" title="文章设置">
<div class="settings-body">
<div class="settings-field">
<div class="settings-label">发布账号</div>
<Select
v-model:value="form.account_id"
:options="[{ label: '使用默认账号', value: 0 }, ...accountOptions]"
style="width: 100%"
/>
<div class="settings-help">发布时可再临时切换</div>
</div>
<div class="settings-field">
<div class="settings-label">作者</div>
<Input
v-model:value="form.author"
:maxlength="16"
placeholder="显示在标题下方(选填)"
/>
</div>
<div class="settings-field">
<div class="settings-label">摘要</div>
<Textarea
v-model:value="form.digest"
:maxlength="120"
:rows="3"
placeholder="转发卡片上的摘要文字,留空自动截取正文开头"
show-count
/>
</div>
<div class="settings-field">
<div class="settings-label">封面图发布必填</div>
<UploadImage
v-model="coverList"
:max-count="1"
:multiple="false"
/>
<div class="settings-help">
建议 2.35:1 尺寸jpg/png 且小于 1MB微信素材要求
</div>
</div>
</div>
</SettingsDrawer>
</div>
</template>
<style scoped>
.editor-root {
display: flex;
flex-direction: column;
height: 100%;
background: hsl(var(--background));
}
.editor-header {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
flex-shrink: 0;
}
.title-input {
flex: 1;
min-width: 120px;
font-size: 17px;
font-weight: 600;
}
.header-meta {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.meta-text {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.meta-dirty {
font-size: 12px;
color: hsl(var(--warning, 38 92% 50%));
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.editor-main {
display: flex;
flex: 1;
min-height: 0;
}
.pane {
min-height: 0;
}
.pane-editor {
flex: 1;
min-width: 320px;
display: flex;
flex-direction: column;
border-right: 1px solid hsl(var(--border));
}
.md-textarea {
flex: 1;
width: 100%;
border: none;
outline: none;
resize: none;
padding: 16px;
font-size: 14px;
line-height: 1.8;
font-family: 'SF Mono', Menlo, Consolas, 'PingFang SC', 'Microsoft YaHei', monospace;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
/* 弹性宽度:给平板/笔记本/电脑壳留缩放空间,最小仍保证手机壳完整展示 */
.pane-preview {
flex: 1;
min-width: 430px;
overflow: hidden;
display: flex;
flex-direction: column;
background: hsl(var(--muted) / 0.25);
border-right: 1px solid hsl(var(--border));
}
.pane-themes {
width: 320px;
flex-shrink: 0;
overflow: hidden;
}
.copy-item {
display: flex;
flex-direction: column;
}
.copy-tip {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.settings-body {
display: flex;
flex-direction: column;
gap: 18px;
}
.settings-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-label {
font-size: 14px;
font-weight: 500;
color: hsl(var(--foreground));
}
.settings-help {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,91 @@
/**
* 剪贴板工具:支持「带格式 HTML」与「纯文本」两种复制
* 公众号编辑器识别的是剪贴板里的 text/html 富文本,
* 优先用异步 Clipboard API需 https/localhost失败时退回 execCommand 方案
*/
/** HTML 转纯文本(作为 text/plain 兜底内容,粘贴到纯文本框时可读) */
function htmlToPlainText(html: string): string {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent ?? '';
}
/**
* execCommand 兜底:把 HTML 放进临时 contenteditable 容器,选中后执行 copy
* 为什么保留http 环境(内网部署)下 navigator.clipboard.write 不可用
*/
function copyHtmlByExecCommand(html: string): boolean {
const container = document.createElement('div');
container.innerHTML = html;
// 移出可视区但保持可选中display:none 会导致选区失效)
container.style.position = 'fixed';
container.style.left = '-9999px';
container.style.top = '0';
container.setAttribute('contenteditable', 'true');
document.body.append(container);
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(container);
selection?.removeAllRanges();
selection?.addRange(range);
let ok = false;
try {
ok = document.execCommand('copy');
} catch {
ok = false;
}
selection?.removeAllRanges();
container.remove();
return ok;
}
/**
* 复制带格式的 HTML公众号/知乎粘贴用)
* 同时写入 text/html 与 text/plain 两种 MIME粘贴目标自行选择
*/
export async function copyHtml(html: string): Promise<boolean> {
const plain = htmlToPlainText(html);
if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([plain], { type: 'text/plain' }),
}),
]);
return true;
} catch {
// 继续走 execCommand 兜底
}
}
return copyHtmlByExecCommand(html);
}
/**
* 复制纯文本Markdown 原文 / 小红书文案用)
*/
export async function copyText(text: string): Promise<boolean> {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// 继续走 textarea 兜底
}
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.append(textarea);
textarea.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch {
ok = false;
}
textarea.remove();
return ok;
}

View File

@@ -0,0 +1,152 @@
/**
* 暗色阅读模拟:近似微信客户端深色模式的配色映射
*
* 微信深色模式的规律:浅背景压暗、深文字提亮、品牌色(高饱和)基本保留。
* 这里对渲染产物 HTML 的内联 style 做同样的「按属性区分」的亮度映射,
* 仅用于编辑器预览(发布仍是原始配色,微信客户端会自己做深色转换)。
*
* 设计取舍:
* - 只处理不透明色hex / rgb()rgba 半透明色叠在深底上天然变暗,改了反而失真
* - box-shadow / text-shadow 不动(霓虹光晕在深底上效果正确)
* - 按 CSS 属性分三类规则:文字(提亮深色)、背景(压暗浅色)、边框(压暗浅灰)
*/
/** 解析 #hex / rgb() 为 [r, g, b];解析失败返回 null保持原样不替换 */
function parseColorToken(token: string): [number, number, number] | null {
if (token.startsWith('#')) {
let value = token.slice(1);
if (value.length === 3) {
value = [...value].map((c) => c + c).join('');
}
if (value.length < 6) return null;
const num = Number.parseInt(value.slice(0, 6), 16);
if (Number.isNaN(num)) return null;
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}
const match = token.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
if (match) {
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
return null;
}
/** 相对亮度 0~1Rec. 709 加权) */
function luminanceOf([r, g, b]: [number, number, number]): number {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
/** 饱和度 0~1HSV 口径),用来区分「灰阶」与「品牌色」 */
function saturationOf([r, g, b]: [number, number, number]): number {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
return max === 0 ? 0 : (max - min) / max;
}
/** 与目标色混合(暗色场景下的提亮/压暗底层实现) */
function mix(
[r, g, b]: [number, number, number],
to: number,
weight: number,
): [number, number, number] {
const step = (from: number) => Math.round(from + (to - from) * weight);
return [step(r), step(g), step(b)];
}
function toHex([r, g, b]: [number, number, number]): string {
const hex = (v: number) => v.toString(16).padStart(2, '0');
return `#${hex(r)}${hex(g)}${hex(b)}`;
}
/** 文字色规则:深色灰阶 → 提亮为浅灰;深色品牌色 → 掺白提亮;浅色保持 */
function mapTextColor(rgb: [number, number, number]): string {
const lum = luminanceOf(rgb);
const sat = saturationOf(rgb);
if (sat < 0.35) {
if (lum < 0.5) {
// 越深的文字提得越亮:#000 → ~#dbdbdb#666 → ~#c3c3c3
const v = Math.round(255 * (0.86 - lum * 0.35));
return toHex([v, v, v]);
}
return toHex(rgb);
}
// 深的品牌色文字(如 darken 后的主色)掺白提亮,保证深底可读
return lum < 0.35 ? toHex(mix(rgb, 255, 0.45)) : toHex(rgb);
}
/** 背景色规则:浅色灰阶 → 压暗为纸面深灰;极浅品牌色 → 大幅压暗;其余保持 */
function mapBgColor(rgb: [number, number, number]): string {
const lum = luminanceOf(rgb);
const sat = saturationOf(rgb);
if (sat < 0.35) {
if (lum > 0.7) {
// 白/米白纸面 → #17~#2a 的深灰
const v = Math.round(255 * (0.09 + (1 - lum) * 0.2));
return toHex([v, v, v]);
}
return toHex(rgb);
}
// 高亮的品牌浅底(如奶油黄)压暗,色相保留
return lum > 0.8 ? toHex(mix(rgb, 16, 0.7)) : toHex(rgb);
}
/** 边框色规则:浅灰线 → 深灰线;其余保持 */
function mapBorderColor(rgb: [number, number, number]): string {
const lum = luminanceOf(rgb);
const sat = saturationOf(rgb);
if (sat < 0.35 && lum > 0.6) {
return '#3a3b40';
}
return toHex(rgb);
}
type ColorMode = 'bg' | 'border' | 'skip' | 'text';
/** 按 CSS 属性名归类映射规则 */
function modeOfProperty(prop: string): ColorMode {
const name = prop.trim().toLowerCase();
if (name === 'color' || name === '-webkit-text-fill-color') return 'text';
if (name.startsWith('background')) return 'bg';
if (name.startsWith('border') || name.startsWith('outline')) return 'border';
// 阴影/光晕等保持原样(深底上光晕效果本来就正确)
return 'skip';
}
/** 匹配不透明颜色 token#hex 或 rgb()(刻意不匹配 rgba */
const COLOR_TOKEN_RE = /#[0-9a-f]{3,6}\b|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)/gi;
/** 对单条声明的值做颜色替换 */
function mapDeclarationValue(value: string, mode: ColorMode): string {
if (mode === 'skip') return value;
return value.replaceAll(COLOR_TOKEN_RE, (token) => {
const rgb = parseColorToken(token);
if (!rgb) return token;
if (mode === 'text') return mapTextColor(rgb);
if (mode === 'bg') return mapBgColor(rgb);
return mapBorderColor(rgb);
});
}
/** 对一个 style 属性字符串(多条声明)做映射 */
function mapStyleAttr(style: string): string {
return style
.split(';')
.map((decl) => {
const idx = decl.indexOf(':');
if (idx === -1) return decl;
const prop = decl.slice(0, idx);
const value = decl.slice(idx + 1);
return `${prop}:${mapDeclarationValue(value, modeOfProperty(prop))}`;
})
.join(';');
}
/**
* 把「亮色排版 HTML」转换为「暗色阅读模拟 HTML」
* 只改 style="" 内的颜色,不动结构与文本,供预览组件在暗色模式下展示
*/
export function adaptHtmlForDarkMode(html: string): string {
return html.replaceAll(
/style="([^"]*)"/g,
(_all, style: string) => `style="${mapStyleAttr(style)}"`,
);
}

View File

@@ -0,0 +1,112 @@
import type { WechatTheme } from '../themes';
import markdownit from 'markdown-it';
import { copyHtml, copyText } from './copy';
import { renderArticleHtml } from './renderer';
/**
* 多平台一键复制:同一份 Markdown 按目标平台特性输出不同格式
* - 公众号:带内联样式的完整 HTML粘贴进公众号编辑器排版不丢
* - 知乎:语义化干净 HTML知乎编辑器会重排样式塞内联样式反而出脏格式
* - 掘金Markdown 原文(掘金编辑器原生 MD
* - 小红书:纯文本 + emoji 排版(小红书无富文本,靠符号断行做层次)
*/
export type PlatformKey = 'juejin' | 'wechat' | 'xiaohongshu' | 'zhihu';
export interface PlatformMeta {
key: PlatformKey;
name: string;
/** 复制按钮下的小字说明 */
tip: string;
}
export const PLATFORMS: PlatformMeta[] = [
{ key: 'wechat', name: '公众号', tip: '带排版 HTML直接粘贴到公众号编辑器' },
{ key: 'zhihu', name: '知乎', tip: '干净 HTML粘贴后知乎自动接管样式' },
{ key: 'juejin', name: '掘金', tip: 'Markdown 原文,掘金编辑器原生支持' },
{ key: 'xiaohongshu', name: '小红书', tip: '纯文本 emoji 排版,适合笔记正文' },
];
/** 知乎用:无自定义规则的干净渲染器(保留语义标签,去掉内联样式) */
const plainMd = markdownit({ html: false, linkify: true, breaks: true });
/**
* Markdown → 小红书风格纯文本
* 规则:标题加 emoji 前缀、列表转圆点、引用转 💬、图片/链接降级为文字,代码块保留内容
*/
export function markdownToXiaohongshu(markdown: string): string {
let text = markdown ?? '';
// 代码块:去掉围栏只留内容(小红书没有代码概念)
text = text.replaceAll(/```[^\n]*\n([\s\S]*?)```/g, '$1');
// 图片先于链接处理(语法是链接的超集):直接移除(小红书图片走相册上传,正文里的图片语法无意义)
text = text.replaceAll(/!\[([^\]]*)\]\([^)]*\)/g, '');
// 链接:只留文字
text = text.replaceAll(/\[([^\]]+)\]\([^)]*\)/g, '$1');
const lines = text.split('\n').map((line) => {
const trimmed = line.trimEnd();
// 标题:# 级别越高 emoji 越醒目
const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed);
if (heading) {
const level = heading[1]?.length ?? 1;
const title = heading[2] ?? '';
if (level === 1) return `🔥 ${title}`;
if (level === 2) return `${title}`;
return `📌 ${title}`;
}
// 引用 → 💬
if (/^>\s?/.test(trimmed)) {
return `💬 ${trimmed.replace(/^>\s?/, '')}`;
}
// 无序列表 → 圆点
if (/^[-*+]\s+/.test(trimmed)) {
return `${trimmed.replace(/^[-*+]\s+/, '')}`;
}
// 分割线 → 波浪
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
return '〰️〰️〰️〰️〰️';
}
return trimmed;
});
let result = lines.join('\n');
// 加粗/斜体标记去掉(小红书不支持),保留文字
result = result.replaceAll(/\*{1,3}([^*]+)\*{1,3}/g, '$1');
// 行内代码去掉反引号
result = result.replaceAll(/`([^`]+)`/g, '$1');
// 连续 3+ 空行压缩为 1 个空行
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result.trim();
}
/**
* 按平台复制到剪贴板,返回是否成功
* @param platform 目标平台
* @param markdown 正文 Markdown
* @param theme 当前主题(公众号格式需要)
* @param color 当前主色(公众号格式需要)
*/
export async function copyForPlatform(
platform: PlatformKey,
markdown: string,
theme: WechatTheme,
color: string,
): Promise<boolean> {
switch (platform) {
case 'juejin': {
return copyText(markdown ?? '');
}
case 'wechat': {
return copyHtml(renderArticleHtml(markdown, theme, color));
}
case 'xiaohongshu': {
return copyText(markdownToXiaohongshu(markdown));
}
case 'zhihu': {
return copyHtml(plainMd.render(markdown ?? ''));
}
default: {
return false;
}
}
}

View File

@@ -0,0 +1,352 @@
import type { Renderer, Token } from 'markdown-it';
import type { ThemeStyleSheet, WechatTheme } from '../themes';
import markdownit from 'markdown-it';
/**
* 主题渲染器Markdown → 带内联样式的 HTML
* 公众号编辑器会剥离 <style> 标签和 class只保留 style="" 内联样式,
* 所以这里用 markdown-it 的自定义渲染规则给每个标签注入主题样式
*
* 设计要点:
* - markdown-it 实例全局只建一个;主题/颜色通过 env 传入规则函数,避免每次渲染重建实例
* - 标题装饰emoji/引号/编号徽章)以 <span> 注入到标题内部,同样使用内联样式
* - 规则函数的 env 形参用 any 声明markdown-it 的 Env 类型是 unknown
* 直接标注具体类型会因参数逆变报 TS2322进函数后立刻收窄为 RenderEnv
*/
/**
* 渲染环境:规则函数从这里拿当前主题的样式表
* 用 type 而非 interfacetype 拥有隐式索引签名,才能匹配 markdown-it 的 Env 约束
*/
type RenderEnv = {
styles: ThemeStyleSheet;
theme: WechatTheme;
color: string;
/** h2 自动编号计数器numbering 主题用) */
sectionNo: number;
};
const md = markdownit({
html: false, // 禁止直接写 HTML防 XSS正文以 Markdown 为唯一输入)
linkify: true,
breaks: true,
});
/** 简单地给 open 标签塞一个 style 属性 */
function styledOpen(tag: keyof ThemeStyleSheet) {
return (
tokens: Token[],
idx: number,
options: any,
env: any,
self: Renderer,
) => {
const renderEnv = env as RenderEnv;
tokens[idx]?.attrSet('style', renderEnv.styles[tag] ?? '');
return self.renderToken(tokens, idx, options);
};
}
/** HTML 转义(装饰字符注入前处理,防止主题配置里出现特殊字符破坏结构) */
function escapeHtml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/** h1~h6 归一化到 h1/h2/h3 三级样式(公众号正文层级不宜过深) */
function headingKey(tag: string): 'h1' | 'h2' | 'h3' {
const level = Number(tag.slice(1));
return level <= 1 ? 'h1' : level === 2 ? 'h2' : 'h3';
}
/**
* 标题渲染:注入装饰字符(前缀/后缀)与 h2 自动编号徽章
*/
md.renderer.rules.heading_open = (
tokens: Token[],
idx: number,
_options: any,
env: any,
) => {
const renderEnv = env as RenderEnv;
const token = tokens[idx] as Token;
const styleKey = headingKey(token.tag);
const style = renderEnv.styles[styleKey];
let injected = '';
// h2 自动编号01 / 02 / ...,编号徽章样式由主题给出
if (
styleKey === 'h2' &&
renderEnv.theme.numbering &&
renderEnv.theme.numberStyle
) {
renderEnv.sectionNo += 1;
const no = String(renderEnv.sectionNo).padStart(2, '0');
injected += `<span style="${renderEnv.theme.numberStyle(renderEnv.color)}">${no}</span>`;
}
// 装饰前缀(❝ / emoji / 「 等用主色着色emoji 不受 color 影响,无副作用)
const decor = renderEnv.theme.decor?.[styleKey];
if (decor?.[0]) {
injected += `<span style="color: ${renderEnv.color};">${escapeHtml(decor[0])}</span>`;
}
return `<${token.tag} style="${style}">${injected}`;
};
md.renderer.rules.heading_close = (
tokens: Token[],
idx: number,
_options: any,
env: any,
) => {
const renderEnv = env as RenderEnv;
const token = tokens[idx] as Token;
const styleKey = headingKey(token.tag);
const decor = renderEnv.theme.decor?.[styleKey];
const suffix = decor?.[1]
? `<span style="color: ${renderEnv.color};">${escapeHtml(decor[1])}</span>`
: '';
return `${suffix}</${token.tag}>`;
};
/** 段落blockquote 内的段落用引用文字样式(颜色更浅),普通段落用正文样式 */
md.renderer.rules.paragraph_open = (
tokens: Token[],
idx: number,
options: any,
env: any,
self: Renderer,
) => {
const renderEnv = env as RenderEnv;
// 向前找最近的未闭合 blockquote判断当前段落是否在引用内
let inBlockquote = false;
for (let i = idx - 1; i >= 0; i--) {
const type = tokens[i]?.type;
if (type === 'blockquote_open') {
inBlockquote = true;
break;
}
if (type === 'blockquote_close') break;
}
tokens[idx]?.attrSet(
'style',
inBlockquote ? renderEnv.styles.blockquoteP : renderEnv.styles.p,
);
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.blockquote_open = styledOpen('blockquote');
md.renderer.rules.bullet_list_open = styledOpen('ul');
md.renderer.rules.ordered_list_open = styledOpen('ol');
md.renderer.rules.list_item_open = styledOpen('li');
md.renderer.rules.strong_open = styledOpen('strong');
md.renderer.rules.em_open = styledOpen('em');
md.renderer.rules.hr = styledOpen('hr');
md.renderer.rules.table_open = styledOpen('table');
md.renderer.rules.th_open = styledOpen('th');
md.renderer.rules.td_open = styledOpen('td');
/** 链接:公众号会把外链剥成纯文本,但预览/其他平台仍保留样式 */
md.renderer.rules.link_open = styledOpen('a');
/** 图片:撑满宽度 + 圆角;沿用默认渲染保留 src/alt */
const defaultImageRule = md.renderer.rules.image?.bind(md.renderer);
md.renderer.rules.image = (
tokens: Token[],
idx: number,
options: any,
env: any,
self: Renderer,
) => {
const renderEnv = env as RenderEnv;
tokens[idx]?.attrSet('style', renderEnv.styles.img);
return defaultImageRule
? defaultImageRule(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options);
};
// ===== 智能相册:连续图片自动拼图 =====
/** 相册里的一张图 */
type AlbumImage = { alt: string; src: string };
/**
* 判断 inline token 是否是「纯图片行」:全部子节点只有图片、软换行和空白文本
* 是则返回图片列表,否则返回 null
*/
function pureImagesOf(inline: Token): AlbumImage[] | null {
const children = inline.children ?? [];
const images: AlbumImage[] = [];
for (const child of children) {
if (child.type === 'image') {
images.push({
// attrGet 类型可能是 string | number统一转成字符串再用
src: String(child.attrGet('src') ?? ''),
alt: child.content ?? '',
});
continue;
}
if (child.type === 'softbreak' || child.type === 'hardbreak') continue;
if (child.type === 'text' && child.content.trim() === '') continue;
return null;
}
return images.length > 0 ? images : null;
}
/**
* core 规则:把「相邻的纯图片段落」合并为一个 wechat_album 块级 token
* 为什么在 core 层做:段落合并需要跨 token 三元组open/inline/close扫描
* 渲染规则层只能看到单个 token处理不了「相邻段落归组」
*/
md.core.ruler.push('wechat_album', (state) => {
const tokens = state.tokens;
const out: Token[] = [];
let i = 0;
while (i < tokens.length) {
const isParagraph =
tokens[i]?.type === 'paragraph_open' &&
tokens[i + 1]?.type === 'inline' &&
tokens[i + 2]?.type === 'paragraph_close';
if (isParagraph) {
const first = pureImagesOf(tokens[i + 1] as Token);
if (first) {
// 向后吞并相邻的纯图片段落(中间隔了标题/文字等任何 token 就停)
const collected = [...first];
let j = i + 3;
while (
tokens[j]?.type === 'paragraph_open' &&
tokens[j + 1]?.type === 'inline' &&
tokens[j + 2]?.type === 'paragraph_close'
) {
const more = pureImagesOf(tokens[j + 1] as Token);
if (!more) break;
collected.push(...more);
j += 3;
}
// 两张及以上才组相册;单图保持原段落渲染(自然比例更好看)
if (collected.length >= 2) {
const album = new state.Token('wechat_album', '', 0);
album.block = true;
album.meta = { images: collected };
out.push(album);
i = j;
continue;
}
}
}
out.push(tokens[i] as Token);
i++;
}
state.tokens = out;
});
/**
* 按张数决定每行几张(对齐公众号常见拼图习惯):
* 2=两列3=三列4=2×25=2+36=3+37 张以上每行 3 张,
* 余 1 时最后一张整行(避免孤图占 1/3 太小),余 2 时末行两列
*/
function albumRows(count: number): number[] {
if (count === 2) return [2];
if (count === 3) return [3];
if (count === 4) return [2, 2];
if (count === 5) return [2, 3];
if (count === 6) return [3, 3];
const rows: number[] = [];
let rest = count;
while (rest > 3) {
rows.push(3);
rest -= 3;
}
rows.push(rest); // 1 或 2 或 3
return rows;
}
/** 相册格子间距px用 margin 实现,不用 flex gap兼容旧微信客户端 */
const ALBUM_GAP = 6;
/**
* 渲染相册 HTMLflex 行 + 正方形裁切格子,全部内联样式(公众号安全)
* 圆角跟随主题 img 的 border-radius保证和单图风格统一
*/
function renderAlbum(images: AlbumImage[], styles: ThemeStyleSheet): string {
const radius = /border-radius:\s*([^;]+);/.exec(styles.img)?.[1] ?? '6px';
const rows = albumRows(images.length);
let cursor = 0;
const rowHtml = rows.map((size, rowIdx) => {
const cells = images.slice(cursor, cursor + size);
cursor += size;
const topGap = rowIdx > 0 ? `${ALBUM_GAP}px` : '0';
// 末行单张整行展示:沿用主题图片样式(自然比例),只重置上下间距
if (size === 1 && images.length > 3) {
const one = cells[0] as AlbumImage;
return `<img src="${escapeHtml(one.src)}" alt="${escapeHtml(one.alt)}" style="${styles.img} margin: ${topGap} 0 0; width: 100%;">`;
}
const cellHtml = cells
.map(
(cell, cellIdx) =>
`<img src="${escapeHtml(cell.src)}" alt="${escapeHtml(cell.alt)}" style="flex: 1; min-width: 0; display: block; aspect-ratio: 1 / 1; object-fit: cover; border-radius: ${radius}; margin: 0 0 0 ${cellIdx > 0 ? ALBUM_GAP : 0}px;">`,
)
.join('');
return `<section style="display: flex; margin: ${topGap} 0 0;">${cellHtml}</section>`;
});
return `<section style="margin: 20px 0;">${rowHtml.join('')}</section>`;
}
/** 相册块渲染规则token 由上面的 core 规则生成) */
md.renderer.rules.wechat_album = (
tokens: Token[],
idx: number,
_options: any,
env: any,
) => {
const renderEnv = env as RenderEnv;
const images = (tokens[idx]?.meta?.images ?? []) as AlbumImage[];
return renderAlbum(images, renderEnv.styles);
};
/** 行内代码 */
md.renderer.rules.code_inline = (
tokens: Token[],
idx: number,
_options: any,
env: any,
) => {
const renderEnv = env as RenderEnv;
const content = escapeHtml(tokens[idx]?.content ?? '');
return `<code style="${renderEnv.styles.code}">${content}</code>`;
};
/** 代码块fence 与缩进代码块同样式) */
const renderCodeBlock = (tokens: Token[], idx: number, env: RenderEnv) => {
const content = escapeHtml(tokens[idx]?.content ?? '');
return `<pre style="${env.styles.pre}"><code style="${env.styles.preCode}">${content}</code></pre>`;
};
md.renderer.rules.fence = (
tokens: Token[],
idx: number,
_options: any,
env: any,
) => renderCodeBlock(tokens, idx, env as RenderEnv);
md.renderer.rules.code_block = (
tokens: Token[],
idx: number,
_options: any,
env: any,
) => renderCodeBlock(tokens, idx, env as RenderEnv);
/**
* 渲染文章正文Markdown + 主题 + 主色 → 内联样式 HTML
* 输出用 <section> 包裹(公众号编辑器对 section 兼容最好),可直接粘贴/发布
*/
export function renderArticleHtml(
markdown: string,
theme: WechatTheme,
color: string,
): string {
const styles = theme.styles(color);
const env: RenderEnv = { styles, theme, color, sectionNo: 0 };
const body = md.render(markdown ?? '', env);
return `<section style="${styles.container}">${body}</section>`;
}

View File

@@ -0,0 +1,114 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, gradient, rgba } from './helpers';
/**
* 商务组5 个):同为「稳」,五套身份各异——
* 色块=标准公文;双线=纸质报告;编号徽章=清单简报;角标=灰底卡片系统;深色条=深蓝主视觉
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-size: 15px; line-height: 1.75; color: #374151; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 16px; font-size: 15px; line-height: 1.75; color: #374151;`,
ul: `margin: 0 0 16px; padding-left: 22px; list-style-type: square;`,
li: `margin: 5px 0; font-size: 15px; line-height: 1.75; color: #374151;`,
...overrides,
});
export const businessThemes: WechatTheme[] = [
{
key: 'business-block',
name: '商务·色块',
category: 'business',
description: '实色标题块 + 灰卡引用,标准公文的力度',
palette: ['#1677ff', '#0f766e', '#c2410c', '#334155', '#7c3aed', '#b91c1c'],
styles: (color) =>
persona(color, {
h2: `font-size: 17px; font-weight: bold; color: #ffffff; line-height: 1.5; margin: 32px 0 16px; padding: 8px 14px; background: ${color}; border-radius: 3px;`,
h3: `font-size: 15px; font-weight: bold; color: ${darken(color, 0.15)}; line-height: 1.5; margin: 24px 0 12px; padding: 4px 10px; background: ${rgba(color, 0.08)}; border-left: 3px solid ${color};`,
blockquote: `margin: 20px 0; padding: 14px 16px; border-left: 4px solid ${color}; background: #f6f7f9;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.75; color: #6b7280;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 18px auto; border: 1px solid #e5e7eb;`,
hr: `border: none; border-top: 3px double #e5e7eb; margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.12)};`,
th: `border: 1px solid #e5e7eb; padding: 9px 12px; background: ${darken(color, 0.1)}; color: #ffffff; font-weight: bold; text-align: left;`,
}),
},
{
key: 'business-double-line',
name: '商务·双线',
category: 'business',
description: '通篇粗细双线的纸质年报,引用也是双线框',
palette: ['#0f766e', '#1677ff', '#334155', '#9f1239', '#b45309', '#4338ca'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.8; color: #333d4b; word-break: break-word; text-align: left; padding: 16px 12px 8px; border-top: 3px solid ${darken(color, 0.05)}; border-bottom: 1px solid #d9dde3;`,
h2: `font-size: 18px; font-weight: bold; color: #1f2937; line-height: 1.5; margin: 32px 0 16px; padding: 10px 0 8px; border-top: 3px solid ${color}; border-bottom: 1px solid #d9dde3;`,
h3: `display: inline-block; font-size: 16px; font-weight: bold; color: #1f2937; line-height: 1.5; margin: 24px 0 12px; padding-bottom: 6px; border-bottom: 1px solid ${rgba(color, 0.4)};`,
blockquote: `margin: 22px 0; padding: 12px 16px; border-top: 1px solid ${rgba(color, 0.5)}; border-bottom: 3px solid ${rgba(color, 0.5)}; background: none;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.8; color: #64748b;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 20px auto; border-top: 1px solid #d9dde3; border-bottom: 1px solid #d9dde3; padding: 8px 0;`,
hr: `border: none; border-top: 1px solid #d9dde3; margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.1)}; border-bottom: 1px solid ${rgba(color, 0.5)};`,
}),
},
{
key: 'business-badge',
name: '商务·编号徽章',
category: 'business',
description: '圆徽编号 + 药丸重点 + 圆角卡引用,清单式简报',
palette: ['#c2410c', '#1677ff', '#0f766e', '#7c3aed', '#334155', '#be123c'],
numbering: true,
numberStyle: (color) =>
`display: inline-block; min-width: 26px; height: 26px; line-height: 26px; text-align: center; background: ${color}; color: #ffffff; border-radius: 50%; font-size: 13px; font-weight: bold; margin-right: 10px; vertical-align: 2px;`,
styles: (color) =>
persona(color, {
h2: `font-size: 18px; font-weight: bold; color: #1f2937; line-height: 1.6; margin: 32px 0 16px; padding-bottom: 8px; border-bottom: 2px solid ${rgba(color, 0.25)};`,
h3: `font-size: 15px; font-weight: bold; color: ${darken(color, 0.12)}; line-height: 1.5; margin: 24px 0 12px;`,
blockquote: `margin: 20px 0; padding: 13px 16px; background: ${rgba(color, 0.06)}; border: none; border-radius: 10px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.75; color: ${darken(color, 0.22)};`,
img: `max-width: 100%; border-radius: 10px; display: block; margin: 18px auto; box-shadow: 0 3px 12px rgba(17, 24, 39, 0.08);`,
hr: `border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.18)}; background: ${rgba(color, 0.1)}; padding: 0 6px; border-radius: 999px;`,
}),
},
{
key: 'business-corner',
name: '商务·角标',
category: 'business',
description: '浅灰纸面上一张张白卡,后台界面式的卡片系统',
palette: ['#4338ca', '#0f766e', '#1677ff', '#b45309', '#334155', '#9f1239'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.75; color: #374151; word-break: break-word; text-align: left; padding: 18px 14px; background: #f3f5f8; border-radius: 10px;`,
h2: `display: inline-block; font-size: 17px; font-weight: bold; color: ${darken(color, 0.2)}; line-height: 1.5; margin: 30px 0 16px; padding: 7px 16px 7px 12px; background: #ffffff; border-left: 5px solid ${color}; border-radius: 0 18px 18px 0; box-shadow: 0 2px 8px rgba(17, 24, 39, 0.07);`,
h3: `font-size: 15px; font-weight: bold; color: ${darken(color, 0.2)}; line-height: 1.5; margin: 24px 0 12px; padding-left: 10px; border-left: 3px solid ${rgba(color, 0.5)};`,
blockquote: `margin: 20px 0; padding: 13px 16px; background: #ffffff; border: none; border-radius: 10px; box-shadow: 0 2px 8px rgba(17, 24, 39, 0.06);`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.75; color: #6b7280;`,
img: `max-width: 100%; border-radius: 10px; display: block; margin: 18px auto; border: 5px solid #ffffff; box-shadow: 0 3px 12px rgba(17, 24, 39, 0.1);`,
hr: `border: none; border-top: 1px solid #dde1e8; margin: 28px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.12)};`,
}),
},
{
key: 'business-navy',
name: '商务·深色条',
category: 'business',
description: '近黑深条 + 深色引用卡,双色主视觉的发布会风',
palette: ['#1677ff', '#0f766e', '#d97706', '#dc2626', '#7c3aed', '#0891b2'],
styles: (color) =>
persona(color, {
h2: `font-size: 17px; font-weight: bold; color: #ffffff; line-height: 1.5; margin: 32px 0 16px; padding: 9px 14px; background: linear-gradient(90deg, #1f2937 82%, ${color} 82%); border-radius: 2px;`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: #1f2937; line-height: 1.5; margin: 24px 0 12px; padding: 3px 10px; border: 1px solid #1f2937;`,
blockquote: `margin: 20px 0; padding: 14px 16px; background: #1f2937; border-left: 4px solid ${color}; border-radius: 4px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.75; color: #cbd5e1;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 18px auto; border: 1px solid #1f2937; padding: 4px; background: #ffffff;`,
hr: `border: none; height: 2px; background: ${gradient(color, 90, '#1f2937')}; margin: 30px 0;`,
strong: `font-weight: bold; color: #ffffff; background: #1f2937; padding: 0 6px; border-radius: 3px;`,
th: `border: 1px solid #e5e7eb; padding: 9px 12px; background: #1f2937; color: #ffffff; font-weight: bold; text-align: left;`,
}),
},
];

View File

@@ -0,0 +1,123 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, FONT_SERIF, rgba } from './helpers';
/**
* 中国风组5 个):同为「宋体」,五套身份各异——
* 印章=宣纸红印;卷轴=上下轴装裱;题字=白纸墨块;方胜=织锦格纹;竹简=竖纹木色
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2; color: #433c33; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 18px; font-size: 15px; line-height: 2; color: #433c33;`,
li: `margin: 6px 0; font-size: 15px; line-height: 2; color: #433c33;`,
em: `font-style: normal; color: ${darken(color, 0.1)}; letter-spacing: 1px;`,
...overrides,
});
export const chineseThemes: WechatTheme[] = [
{
key: 'cn-seal',
name: '国风·印章',
category: 'chinese',
description: '宣纸底 + 朱红印章标题,落款式的仪式感',
palette: ['#a72126', '#8c6239', '#31456b', '#3f5f4f', '#7a3d2c', '#5b4a6b'],
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2; color: #433c33; word-break: break-word; text-align: left; padding: 22px 16px; background: #f8f4e9; border: 1px solid #e5dcc3;`,
h2: `display: inline-block; font-family: ${FONT_SERIF}; font-size: 17px; font-weight: bold; color: #ffffff; line-height: 1.6; margin: 32px 0 16px; padding: 6px 14px; background: ${color}; border-radius: 4px; letter-spacing: 4px; box-shadow: 2px 2px 0 ${rgba(color, 0.3)};`,
h3: `font-family: ${FONT_SERIF}; font-size: 16px; font-weight: bold; color: ${darken(color, 0.1)}; line-height: 1.6; margin: 24px 0 12px; letter-spacing: 3px;`,
blockquote: `margin: 24px 10px; padding: 14px 18px; border: 2px solid ${rgba(color, 0.5)}; outline: 1px solid ${rgba(color, 0.25)}; outline-offset: 3px; background: rgba(255, 255, 255, 0.6);`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: #6d6353; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 22px auto; padding: 6px; background: #ffffff; border: 1px solid #e0d5b8;`,
hr: `border: none; border-top: 1px solid #d8cca9; margin: 32px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.08)};`,
}),
},
{
key: 'cn-scroll',
name: '国风·卷轴',
category: 'chinese',
description: '左右细双线装裱如立轴,展开一幅手卷',
palette: ['#8c6239', '#a72126', '#31456b', '#3f5f4f', '#5b4a6b', '#7a3d2c'],
decor: {
h2: [' ', ' '],
},
styles: (color) =>
persona(color, {
// 顶部粗轴杆是横向色条(用户不要),改为左右细双线的立轴装裱
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2; color: #4a4136; word-break: break-word; text-align: left; padding: 24px 20px; background: #faf6ec; border-left: 3px double ${rgba(color, 0.55)}; border-right: 3px double ${rgba(color, 0.55)};`,
h2: `font-family: ${FONT_SERIF}; font-size: 18px; font-weight: bold; color: ${darken(color, 0.12)}; line-height: 1.7; margin: 34px 0 16px; text-align: center; letter-spacing: 4px;`,
h3: `font-family: ${FONT_SERIF}; font-size: 16px; font-weight: bold; color: #5a4f40; line-height: 1.6; margin: 24px 0 12px; text-align: center; letter-spacing: 3px;`,
blockquote: `margin: 24px 20px; padding: 0; border: none; background: none; text-align: center;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2.1; color: ${darken(color, 0.15)}; letter-spacing: 2px;`,
img: `max-width: 92%; border-radius: 0; display: block; margin: 24px auto; border: 6px solid #ffffff; outline: 1px solid #d9cba6; box-shadow: 0 3px 10px rgba(59, 50, 38, 0.15);`,
hr: `border: none; border-top: 1px dashed ${rgba(color, 0.5)}; width: 150px; margin: 32px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.1)}; letter-spacing: 1px;`,
}),
},
{
key: 'cn-brush',
name: '国风·题字',
category: 'chinese',
description: '白纸黑墨:墨块题字 + 墨点引用,浓淡相宜',
palette: ['#2f2f2f', '#a72126', '#31456b', '#3f5f4f', '#8c6239', '#5b4a6b'],
decor: {
h2: ['「', '」'],
},
styles: (color) =>
persona(color, {
h2: `display: inline-block; font-family: ${FONT_SERIF}; font-size: 19px; font-weight: bold; color: #ffffff; line-height: 1.6; margin: 32px 0 18px; padding: 6px 18px; background: #2b2b2b; letter-spacing: 5px; border-radius: 2px 12px 2px 12px;`,
h3: `font-family: ${FONT_SERIF}; font-size: 16px; font-weight: bold; color: #2b2b2b; line-height: 1.6; margin: 24px 0 12px; letter-spacing: 3px; padding-left: 12px; border-left: 4px solid ${color};`,
blockquote: `margin: 24px 8px; padding: 12px 18px; background: rgba(43, 43, 43, 0.045); border: none; border-radius: 2px 16px 2px 16px;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: #5c564c; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 22px auto; box-shadow: 6px 6px 0 rgba(43, 43, 43, 0.12);`,
hr: `border: none; height: 3px; background: linear-gradient(90deg, #2b2b2b, ${rgba(color, 0.6)} 60%, transparent); width: 120px; margin: 34px 0;`,
strong: `font-weight: bold; color: #ffffff; background: #2b2b2b; padding: 0 6px; border-radius: 3px;`,
}),
},
{
key: 'cn-double-ring',
name: '国风·方胜',
category: 'chinese',
description: '双线嵌套框 + 白卡引用,传统纹样的秩序',
palette: ['#31456b', '#a72126', '#3f5f4f', '#8c6239', '#5b4a6b', '#7a3d2c'],
styles: (color) =>
persona(color, {
// 格纹底压正文影响阅读,去掉后个性由双线嵌套框(容器/标题)承担
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2; color: #3f4453; word-break: break-word; text-align: left; padding: 22px 16px; background: #f6f6f2; border: 2px solid ${rgba(color, 0.4)};`,
h2: `display: inline-block; font-family: ${FONT_SERIF}; font-size: 17px; font-weight: bold; color: ${darken(color, 0.15)}; line-height: 1.6; margin: 32px 0 16px; padding: 5px 16px; border: 1px solid ${rgba(color, 0.6)}; outline: 1px solid ${rgba(color, 0.3)}; outline-offset: 3px; letter-spacing: 4px; background: #ffffff;`,
h3: `font-family: ${FONT_SERIF}; font-size: 15px; font-weight: bold; color: ${darken(color, 0.12)}; line-height: 1.6; margin: 24px 0 12px; letter-spacing: 2px;`,
blockquote: `margin: 24px 8px; padding: 13px 16px; background: #ffffff; border-left: 1px solid ${rgba(color, 0.6)}; border-right: 1px solid ${rgba(color, 0.6)};`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: #666c7a; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 22px auto; border: 1px solid ${rgba(color, 0.45)}; padding: 5px; background: #ffffff;`,
hr: `border: none; border-top: 3px double ${rgba(color, 0.55)}; margin: 32px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.12)};`,
}),
},
{
key: 'cn-bamboo',
name: '国风·竹简',
category: 'chinese',
description: '暖木色纸面 + 上下双线引用,竹简气质的沉稳',
palette: ['#3f5f4f', '#8c6239', '#a72126', '#31456b', '#7a3d2c', '#5b4a6b'],
decor: {
h2: ['◈ ', ''],
},
styles: (color) =>
persona(color, {
// 竖纹压正文影响阅读,去掉后靠暖木纸色 + 衬线 + ◈ 装饰保留竹简气质
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2.05; color: #4b4234; word-break: break-word; text-align: left; padding: 24px 18px; background: #f3ecdc; border-radius: 6px; border: 1px solid #dfd3b4;`,
h2: `font-family: ${FONT_SERIF}; font-size: 17px; font-weight: bold; color: ${darken(color, 0.15)}; line-height: 1.7; margin: 32px 0 16px; letter-spacing: 4px; padding-bottom: 8px; border-bottom: 2px solid ${rgba(color, 0.45)};`,
h3: `font-family: ${FONT_SERIF}; font-size: 15px; font-weight: bold; color: #5a4f3e; line-height: 1.6; margin: 24px 0 12px; letter-spacing: 3px;`,
blockquote: `margin: 24px 6px; padding: 13px 16px; background: rgba(255, 255, 255, 0.68); border-top: 2px solid ${rgba(color, 0.45)}; border-bottom: 2px solid ${rgba(color, 0.45)};`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: #6d6047; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 4px; display: block; margin: 22px auto; border: 5px solid rgba(255, 255, 255, 0.85); box-shadow: 0 3px 10px rgba(90, 76, 50, 0.18);`,
hr: `border: none; border-top: 1px dashed #c9b98f; margin: 32px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.12)}; border-bottom: 1px solid ${rgba(color, 0.55)};`,
}),
},
];

View File

@@ -0,0 +1,137 @@
/**
* 自定义主题运行时注册表(「我的模板」)
*
* 用户以 JSON 形式上传「元素 → 内联 CSS」映射后端清洗入库nl_wechat_theme
* 前端拉取后在这里注册成与内置主题同构的 WechatTheme 对象,渲染链路完全复用。
*
* 设计要点:
* - registry 用 reactive MapgetTheme 在 computed 里读它,列表异步加载完成后预览自动重渲染
* - JSON 未覆盖的元素用基底主题(默认极简主题)补齐,保证 ThemeStyleSheet 全键都有值
* - 样式值支持 {{color}} 占位符,换主色时实时替换(与内置主题的 styles(color) 行为一致)
* - 前端再做一次样式清洗(与两端后端同口径),纵深防御防 style 属性逃逸注入
*/
import type { ThemeStyleSheet, WechatTheme } from './types';
import { reactive } from 'vue';
import { minimalThemes } from './minimal';
/** 「我的模板」伪分类 key不进内置 THEME_CATEGORIES由主题面板单独追加 chip */
export const CUSTOM_CATEGORY_KEY = 'custom';
/** 自定义主题 key 前缀custom-<数据库id>,入库存 nl_wechat_article.theme_key */
const CUSTOM_KEY_PREFIX = 'custom-';
/** styles 白名单键(与 Laravel/GF 后端 ALLOWED_STYLE_KEYS 同口径,改动需三端同步) */
export const CUSTOM_STYLE_KEYS: (keyof ThemeStyleSheet)[] = [
'container',
'h1',
'h2',
'h3',
'p',
'blockquote',
'blockquoteP',
'ul',
'ol',
'li',
'strong',
'em',
'a',
'hr',
'img',
'code',
'pre',
'preCode',
'table',
'th',
'td',
];
/** 兜底基底主题:自定义 JSON 没写的元素样式用它补齐,保证渲染不缺键 */
const baseTheme =
minimalThemes.find((theme) => theme.key === 'minimal-left-line') ??
(minimalThemes[0] as WechatTheme);
/** 已注册的自定义主题key → WechatThemereactive 保证异步加载后预览自动刷新 */
const registry = reactive(new Map<string, WechatTheme>());
/**
* 样式值清洗(前端侧纵深防御,与后端 cleanStyleValue 同口径):
* 内联 CSS 会被注入 style="",去掉能逃逸属性/注入脚本的字符与片段
*/
export function sanitizeStyleValue(value: string): string {
return value
.replaceAll(/["<>\\]/g, '')
.replaceAll(/javascript\s*:/gi, '')
.replaceAll(/expression\s*\(/gi, '')
.trim()
.slice(0, 2000);
}
/** 是否是自定义主题 key */
export function isCustomThemeKey(key: string): boolean {
return key.startsWith(CUSTOM_KEY_PREFIX);
}
/** 从自定义主题 key 解析数据库行 id删除接口用 */
export function customIdFromKey(key: string): number {
return Number(key.slice(CUSTOM_KEY_PREFIX.length)) || 0;
}
/**
* 后端行数据 → WechatTheme清洗样式、补齐缺失元素、支持 {{color}} 占位
*/
export function buildCustomTheme(row: Record<string, any>): WechatTheme {
const cleanStyles: Partial<Record<keyof ThemeStyleSheet, string>> = {};
const rawStyles = (row?.styles ?? {}) as Record<string, string>;
for (const key of CUSTOM_STYLE_KEYS) {
const raw = rawStyles[key];
if (typeof raw === 'string' && raw.trim() !== '') {
const value = sanitizeStyleValue(raw);
if (value !== '') {
cleanStyles[key] = value;
}
}
}
// 色板只收合法 HEX空色板兜底微信绿保证「点选主题重置默认色」不出 undefined
const palette = (Array.isArray(row?.palette) ? row.palette : [])
.map((item: unknown) => String(item ?? '').trim())
.filter((item: string) => /^#[0-9a-f]{3,8}$/i.test(item));
return {
key: `${CUSTOM_KEY_PREFIX}${row?.id ?? 0}`,
name: String(row?.name ?? '自定义模板'),
category: 'custom',
description: '我的模板 · 导入的自定义排版',
palette: palette.length > 0 ? palette : ['#07c160'],
styles: (color: string) => {
// 先铺基底主题的整套样式再用自定义值覆盖JSON 只写关心的元素也能完整渲染
const sheet: ThemeStyleSheet = { ...baseTheme.styles(color) };
for (const [key, value] of Object.entries(cleanStyles)) {
sheet[key as keyof ThemeStyleSheet] = value.replaceAll(
'{{color}}',
color,
);
}
return sheet;
},
};
}
/** 整体替换注册表(列表接口返回后调用;先清空避免残留已删除的主题) */
export function setCustomThemes(rows: Record<string, any>[]): void {
registry.clear();
for (const row of rows ?? []) {
const theme = buildCustomTheme(row);
registry.set(theme.key, theme);
}
}
/** 按 key 取自定义主题getTheme 的第一优先级) */
export function getCustomTheme(key: string): undefined | WechatTheme {
return registry.get(key);
}
/** 全部自定义主题(主题面板「我的」分类展示,导入时间倒序 = 接口返回顺序) */
export function customThemeList(): WechatTheme[] {
return [...registry.values()];
}

View File

@@ -0,0 +1,124 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, lighten, readableOn, rgba } from './helpers';
/**
* 手账可爱组5 个):同为「软」,五套身份各异——
* 波点=圆点描边胶囊;胶带=奶白纸斜胶带;蜡笔=画纸粗蜡笔描边;软糖=糖色纸果冻块;便签=鹅黄便签纸
* 注意:所有纹理都不铺在正文底下(会影响阅读),只出现在边框/分割线/引用框上
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-size: 15px; line-height: 1.9; color: #5c5550; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 17px; font-size: 15px; line-height: 1.9; color: #5c5550;`,
li: `margin: 6px 0; font-size: 15px; line-height: 1.9; color: #5c5550;`,
...overrides,
});
export const cuteThemes: WechatTheme[] = [
{
key: 'cute-dot',
name: '手账·波点',
category: 'cute',
description: '奶油底圆点描边 + 胶囊标题,圆滚滚的手账第一页',
palette: ['#f59fb0', '#f6bd60', '#84a59d', '#9d8fd6', '#7fb8d4', '#f28482'],
decor: {
h2: ['🎀 ', ''],
},
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.9; color: #5c5550; word-break: break-word; text-align: left; padding: 22px 15px; background: #fffaf5; border-radius: 18px; border: 2px dotted ${rgba(color, 0.55)};`,
h2: `display: inline-block; font-size: 16px; font-weight: bold; color: ${readableOn(color)}; line-height: 1.5; margin: 30px 0 16px; padding: 7px 18px; background: ${color}; border-radius: 999px; box-shadow: 0 4px 10px ${rgba(color, 0.35)};`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${darken(color, 0.25)}; line-height: 1.5; margin: 24px 0 12px; padding: 2px 12px; background: #ffffff; border: 2px solid ${rgba(color, 0.5)}; border-radius: 999px;`,
blockquote: `margin: 20px 0; padding: 13px 18px; background: rgba(255, 255, 255, 0.9); border: 2px dotted ${rgba(color, 0.55)}; border-radius: 18px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: ${darken(color, 0.3)};`,
img: `max-width: 100%; border-radius: 18px; display: block; margin: 20px auto; border: 5px solid #ffffff; box-shadow: 0 4px 12px ${rgba(color, 0.25)};`,
hr: `border: none; border-top: 4px dotted ${rgba(color, 0.5)}; width: 150px; margin: 30px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.22)}; background: #ffffff; padding: 0 6px; border-radius: 999px; border: 1px solid ${rgba(color, 0.45)};`,
}),
},
{
key: 'cute-tape',
name: '手账·胶带',
category: 'cute',
description: '奶白稿纸 + 斜贴的和纸胶带标题,拼贴手账感',
palette: ['#f6bd60', '#f59fb0', '#84a59d', '#7fb8d4', '#9d8fd6', '#e07a5f'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.9; color: #5c5550; word-break: break-word; text-align: left; padding: 22px 15px; background: #fffdf8; border: 1px solid ${rgba(color, 0.3)}; border-radius: 8px;`,
h2: `display: inline-block; font-size: 16px; font-weight: bold; color: ${darken(color, 0.35)}; line-height: 1.5; margin: 30px 0 18px; padding: 8px 22px; background: ${rgba(color, 0.4)}; transform: rotate(-2deg); border-left: 1px dashed rgba(255, 255, 255, 0.9); border-right: 1px dashed rgba(255, 255, 255, 0.9);`,
h3: `display: inline-block; font-size: 14px; font-weight: bold; color: ${darken(color, 0.3)}; line-height: 1.5; margin: 24px 0 12px; padding: 3px 14px; background: ${rgba(color, 0.22)}; transform: rotate(1deg);`,
blockquote: `margin: 22px 4px; padding: 13px 16px; background: #ffffff; border: 1px solid ${rgba(color, 0.4)}; border-radius: 2px; box-shadow: 3px 3px 0 ${rgba(color, 0.2)}; transform: rotate(-0.5deg);`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: #7a726b;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 24px auto; padding: 8px 8px 24px; background: #ffffff; border: 1px solid #eee4d5; box-shadow: 0 4px 12px rgba(120, 100, 70, 0.15); transform: rotate(1deg);`,
hr: `border: none; height: 12px; background: ${rgba(color, 0.3)}; width: 130px; margin: 30px auto; transform: rotate(-1deg); border-left: 2px dashed #ffffff; border-right: 2px dashed #ffffff;`,
strong: `font-weight: bold; color: ${darken(color, 0.28)}; background: linear-gradient(to top, ${rgba(color, 0.4)} 36%, transparent 36%); padding: 0 2px;`,
}),
},
{
key: 'cute-crayon',
name: '手账·蜡笔',
category: 'cute',
description: '奶白画纸 + 粗描边蜡笔框,孩子气的涂鸦本',
palette: ['#84a59d', '#f28482', '#f6bd60', '#7fb8d4', '#9d8fd6', '#f59fb0'],
decor: {
h2: ['✏️ ', ''],
},
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.9; color: #57534c; word-break: break-word; text-align: left; padding: 20px 14px; background: #fffdf6;`,
h2: `display: inline-block; font-size: 16px; font-weight: bold; color: ${darken(color, 0.3)}; line-height: 1.5; margin: 30px 0 16px; padding: 6px 16px; border: 3px solid ${color}; border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px; background: #ffffff;`,
h3: `font-size: 15px; font-weight: bold; color: ${darken(color, 0.25)}; line-height: 1.5; margin: 24px 0 12px; padding-bottom: 3px; border-bottom: 3px solid ${rgba(color, 0.5)};`,
blockquote: `margin: 22px 4px; padding: 13px 16px; background: ${rgba(color, 0.1)}; border: 3px solid ${rgba(color, 0.55)}; border-radius: 15px 255px 15px 225px / 225px 15px 255px 15px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: ${darken(color, 0.32)};`,
img: `max-width: 100%; border-radius: 12px; display: block; margin: 22px auto; border: 3px solid ${rgba(color, 0.6)}; padding: 4px; background: #ffffff;`,
hr: `border: none; border-top: 3px dashed ${rgba(color, 0.55)}; margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.25)}; border-bottom: 3px solid ${rgba(color, 0.6)};`,
}),
},
{
key: 'cute-candy',
name: '手账·软糖',
category: 'cute',
description: '糖色渐变纸 + 果冻厚底块,咬一口的弹性',
palette: ['#9d8fd6', '#f59fb0', '#f6bd60', '#84a59d', '#7fb8d4', '#f28482'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.9; color: #5a5464; word-break: break-word; text-align: left; padding: 22px 15px; background: linear-gradient(180deg, ${lighten(color, 0.9)}, #ffffff 55%, ${lighten(color, 0.92)}); border-radius: 22px; border: 1px solid ${rgba(color, 0.28)};`,
h2: `display: inline-block; font-size: 16px; font-weight: bold; color: ${readableOn(color)}; line-height: 1.5; margin: 30px 0 18px; padding: 8px 20px; background: linear-gradient(180deg, ${lighten(color, 0.25)}, ${color}); border-radius: 18px; box-shadow: 0 5px 0 ${darken(color, 0.18)}, inset 0 2px 3px rgba(255, 255, 255, 0.6);`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${darken(color, 0.28)}; line-height: 1.5; margin: 24px 0 12px; padding: 3px 14px; background: ${rgba(color, 0.16)}; border-radius: 12px; box-shadow: 0 3px 0 ${rgba(color, 0.3)};`,
blockquote: `margin: 22px 0; padding: 14px 18px; background: rgba(255, 255, 255, 0.85); border: none; border-radius: 20px; box-shadow: 0 5px 0 ${rgba(color, 0.25)};`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: ${darken(color, 0.3)};`,
img: `max-width: 100%; border-radius: 20px; display: block; margin: 22px auto; border: 5px solid rgba(255, 255, 255, 0.95); box-shadow: 0 6px 0 ${rgba(color, 0.3)};`,
hr: `border: none; height: 8px; background: ${rgba(color, 0.25)}; border-radius: 4px; width: 140px; margin: 30px auto; box-shadow: 0 3px 0 ${rgba(color, 0.15)};`,
strong: `font-weight: bold; color: ${darken(color, 0.24)}; background: ${rgba(color, 0.16)}; padding: 0 7px; border-radius: 8px;`,
}),
},
{
key: 'cute-memo',
name: '手账·便签',
category: 'cute',
description: '鹅黄便签纸 + 回形针标题,贴在冰箱上的一页',
palette: ['#e0b73c', '#f59fb0', '#84a59d', '#7fb8d4', '#9d8fd6', '#e07a5f'],
decor: {
h2: ['📎 ', ''],
},
styles: (color) =>
persona(color, {
// 去掉顶部胶带条与横线底纹:便签感靠鹅黄纸色 + 圆角 + 悬浮投影表达
container: `font-size: 15px; line-height: 2; color: #6b6046; word-break: break-word; text-align: left; padding: 24px 16px; background: #fdf8e3; border-radius: 14px; box-shadow: 0 6px 16px rgba(150, 130, 80, 0.18);`,
p: `margin: 0 0 18px; font-size: 15px; line-height: 2; color: #6b6046;`,
li: `margin: 7px 0; font-size: 15px; line-height: 2; color: #6b6046;`,
h2: `font-size: 16px; font-weight: bold; color: ${darken(color, 0.35)}; line-height: 1.6; margin: 30px 0 16px; padding-bottom: 4px; border-bottom: 2px solid ${rgba(color, 0.6)};`,
h3: `font-size: 15px; font-weight: bold; color: #7d6f4d; line-height: 1.6; margin: 24px 0 12px;`,
blockquote: `margin: 22px 4px; padding: 12px 16px; background: rgba(255, 255, 255, 0.75); border-left: 6px solid ${rgba(color, 0.6)}; border-radius: 4px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 2; color: #8a7c58;`,
img: `max-width: 100%; border-radius: 6px; display: block; margin: 22px auto; border: 4px solid #ffffff; box-shadow: 0 3px 10px rgba(150, 130, 80, 0.25); transform: rotate(-0.8deg);`,
hr: `border: none; border-top: 2px dashed rgba(190, 170, 110, 0.7); margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.3)}; background: rgba(255, 255, 255, 0.9); padding: 0 5px; border-radius: 4px;`,
}),
},
];

View File

@@ -0,0 +1,130 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, FONT_MONO, gradient, lighten, rgba } from './helpers';
/**
* 暗黑组5 个):同为「黑」,五套黑得不一样——
* 霓虹=纯黑霓虹灯牌;鎏金=暖黑金线沙龙;赛博=蓝黑网格斜切;卡片=灰黑分层卡片;注释=IDE 行号代码稿
*/
const darkBase = (
color: string,
bg: string,
text: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-size: 15px; line-height: 1.9; color: ${text}; word-break: break-word; text-align: left; padding: 24px 16px; background: ${bg}; border-radius: 8px;`,
h1: `font-size: 21px; font-weight: bold; color: #f5f5f5; line-height: 1.45; margin: 28px 0 18px; text-align: center;`,
p: `margin: 0 0 17px; font-size: 15px; line-height: 1.9; color: ${text};`,
li: `margin: 6px 0; font-size: 15px; line-height: 1.9; color: ${text};`,
em: `font-style: italic; color: #e8e8e8;`,
a: `color: ${lighten(color, 0.15)}; text-decoration: none; border-bottom: 1px solid ${rgba(color, 0.5)};`,
code: `font-family: ${FONT_MONO}; font-size: 13px; color: ${lighten(color, 0.25)}; background: rgba(255, 255, 255, 0.08); padding: 2px 6px; border-radius: 4px;`,
pre: `background: rgba(255, 255, 255, 0.06); border-radius: 8px; padding: 14px 16px; overflow-x: auto; margin: 0 0 16px; border: 1px solid rgba(255, 255, 255, 0.1);`,
preCode: `font-family: ${FONT_MONO}; font-size: 13px; line-height: 1.6; color: #e2e2e2; background: none; padding: 0; display: block; white-space: pre-wrap;`,
table: `border-collapse: collapse; width: 100%; margin: 0 0 16px; font-size: 14px;`,
th: `border: 1px solid rgba(255, 255, 255, 0.16); padding: 8px 12px; background: rgba(255, 255, 255, 0.08); color: #f0f0f0; font-weight: bold; text-align: left;`,
td: `border: 1px solid rgba(255, 255, 255, 0.12); padding: 8px 12px; color: ${text};`,
...overrides,
});
export const darkThemes: WechatTheme[] = [
{
key: 'dark-neon',
name: '暗黑·霓虹',
category: 'dark',
description: '纯黑夜幕上的霓虹灯牌,标题和分割线都在发光',
palette: ['#22d3ee', '#f472b6', '#a3e635', '#c084fc', '#fb7185', '#38bdf8'],
styles: (color) =>
darkBase(color, '#101014', '#c8c8cf', {
h2: `display: inline-block; font-size: 17px; font-weight: bold; color: ${lighten(color, 0.2)}; line-height: 1.5; margin: 32px 0 16px; padding: 6px 16px; border: 1px solid ${rgba(color, 0.7)}; border-radius: 10px; text-shadow: 0 0 12px ${rgba(color, 0.9)}; box-shadow: 0 0 14px ${rgba(color, 0.3)}, inset 0 0 14px ${rgba(color, 0.12)};`,
h3: `font-size: 15px; font-weight: bold; color: ${lighten(color, 0.15)}; line-height: 1.5; margin: 24px 0 12px; text-shadow: 0 0 10px ${rgba(color, 0.7)};`,
blockquote: `margin: 22px 0; padding: 13px 16px; background: ${rgba(color, 0.07)}; border-left: 3px solid ${color}; border-radius: 6px; box-shadow: inset 0 0 18px ${rgba(color, 0.08)};`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: ${lighten(color, 0.35)};`,
img: `max-width: 100%; border-radius: 12px; display: block; margin: 22px auto; border: 1px solid ${rgba(color, 0.6)}; box-shadow: 0 0 22px ${rgba(color, 0.35)};`,
hr: `border: none; height: 2px; background: ${color}; box-shadow: 0 0 12px ${rgba(color, 0.9)}; border-radius: 1px; margin: 32px 0;`,
strong: `font-weight: bold; color: ${lighten(color, 0.25)}; text-shadow: 0 0 8px ${rgba(color, 0.8)};`,
}),
},
{
key: 'dark-gold',
name: '暗黑·鎏金',
category: 'dark',
description: '暖黑丝绒 + 金色发丝线,深夜沙龙的贵气',
palette: ['#d4a017', '#c9a227', '#b8860b', '#e6b422', '#cd9b4a', '#daa520'],
styles: (color) =>
darkBase(color, '#191612', '#cfc6b8', {
container: `font-size: 15px; line-height: 1.95; color: #cfc6b8; word-break: break-word; text-align: left; padding: 26px 18px; background: #191612; border: 1px solid ${rgba(color, 0.4)}; outline: 1px solid ${rgba(color, 0.16)}; outline-offset: 4px; border-radius: 4px;`,
h2: `font-size: 17px; font-weight: bold; color: ${lighten(color, 0.2)}; line-height: 1.6; margin: 34px 0 16px; text-align: center; letter-spacing: 4px; padding-bottom: 10px; background: linear-gradient(90deg, transparent 20%, ${rgba(color, 0.8)}, transparent 80%) left bottom no-repeat; background-size: 100% 1px;`,
h3: `display: inline-block; font-size: 14px; font-weight: bold; color: #191612; line-height: 1.5; margin: 24px 0 12px; padding: 3px 14px; background: ${gradient(color, 100, lighten(color, 0.3))}; border-radius: 2px; letter-spacing: 2px;`,
blockquote: `margin: 24px 12px; padding: 14px 18px; background: rgba(255, 255, 255, 0.035); border-top: 1px solid ${rgba(color, 0.5)}; border-bottom: 1px solid ${rgba(color, 0.5)}; text-align: center;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.95; color: ${lighten(color, 0.32)}; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 24px auto; border: 1px solid ${rgba(color, 0.5)}; padding: 6px; background: rgba(255, 255, 255, 0.03);`,
hr: `border: none; height: 1px; background: linear-gradient(90deg, transparent, ${rgba(color, 0.8)}, transparent); margin: 34px 0;`,
strong: `font-weight: bold; color: ${lighten(color, 0.25)}; letter-spacing: 0.5px;`,
}),
},
{
key: 'dark-cyber',
name: '暗黑·赛博',
category: 'dark',
description: '蓝黑底 + 斜切芯片条 + 双色错位,未来都市',
palette: ['#06b6d4', '#f43f5e', '#84cc16', '#8b5cf6', '#f59e0b', '#3b82f6'],
decor: {
h3: ['// ', ''],
},
styles: (color) =>
darkBase(color, '#0d1220', '#b7c2d8', {
// 网格纹理压正文影响阅读,赛博感由斜切标题条 + 双侧引用边线承担
container: `font-size: 15px; line-height: 1.9; color: #b7c2d8; word-break: break-word; text-align: left; padding: 24px 16px; background: #0d1220; border: 1px solid ${rgba(color, 0.35)};`,
h2: `display: inline-block; font-size: 16px; font-weight: 800; color: #0d1220; line-height: 1.5; margin: 32px 0 16px; padding: 6px 20px 6px 14px; background: linear-gradient(100deg, ${color} 88%, transparent 88%); letter-spacing: 1px;`,
h3: `font-family: ${FONT_MONO}; font-size: 14px; font-weight: bold; color: ${lighten(color, 0.2)}; line-height: 1.6; margin: 24px 0 12px; letter-spacing: 1px;`,
blockquote: `margin: 22px 0; padding: 13px 16px; background: rgba(255, 255, 255, 0.04); border-left: 3px solid ${color}; border-right: 3px solid ${rgba(color, 0.25)};`,
blockquoteP: `margin: 0; font-family: ${FONT_MONO}; font-size: 13px; line-height: 1.9; color: ${lighten(color, 0.3)};`,
img: `max-width: 100%; border-radius: 4px; display: block; margin: 22px auto; border: 1px solid ${rgba(color, 0.55)}; box-shadow: 6px 6px 0 ${rgba(color, 0.2)};`,
hr: `border: none; height: 4px; background: repeating-linear-gradient(90deg, ${rgba(color, 0.75)} 0 18px, transparent 18px 26px); margin: 32px 0;`,
strong: `font-weight: bold; color: #0d1220; background: ${lighten(color, 0.1)}; padding: 0 6px;`,
}),
},
{
key: 'dark-card',
name: '暗黑·卡片',
category: 'dark',
description: '灰黑纸面浮起一层层圆角卡,夜间模式的杂志',
palette: ['#818cf8', '#34d399', '#f472b6', '#38bdf8', '#fbbf24', '#a78bfa'],
styles: (color) =>
darkBase(color, '#1a1c22', '#c3c7d1', {
h2: `font-size: 17px; font-weight: bold; color: #f0f1f5; line-height: 1.5; margin: 32px 0 16px; padding: 10px 16px; background: #23262f; border-radius: 12px; border-left: 5px solid ${color}; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${lighten(color, 0.2)}; line-height: 1.5; margin: 24px 0 12px; padding: 3px 12px; background: ${rgba(color, 0.14)}; border-radius: 999px;`,
blockquote: `margin: 22px 0; padding: 14px 18px; background: #23262f; border: none; border-radius: 14px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: #9aa1b0;`,
img: `max-width: 100%; border-radius: 14px; display: block; margin: 22px auto; border: 4px solid #23262f; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);`,
hr: `border: none; border-top: 1px solid #2e323d; margin: 30px 0;`,
strong: `font-weight: bold; color: ${lighten(color, 0.22)}; background: ${rgba(color, 0.14)}; padding: 0 6px; border-radius: 6px;`,
}),
},
{
key: 'dark-code',
name: '暗黑·注释',
category: 'dark',
description: '行号槽 + 等宽注释引用,深夜 IDE 里的一篇文档',
palette: ['#4ade80', '#38bdf8', '#facc15', '#fb7185', '#c084fc', '#22d3ee'],
decor: {
h2: ['## ', ''],
h3: ['### ', ''],
},
styles: (color) =>
darkBase(color, '#101114', '#c2c8cf', {
container: `font-size: 14px; line-height: 1.95; color: #c2c8cf; word-break: break-word; text-align: left; padding: 24px 16px 24px 26px; background-color: #101114; background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.045) 14px, transparent 14px); border-radius: 8px; border: 1px solid #22242a;`,
p: `margin: 0 0 17px; font-size: 14px; line-height: 1.95; color: #c2c8cf;`,
li: `margin: 6px 0; font-size: 14px; line-height: 1.95; color: #c2c8cf;`,
h2: `font-family: ${FONT_MONO}; font-size: 16px; font-weight: bold; color: ${lighten(color, 0.15)}; line-height: 1.6; margin: 32px 0 16px;`,
h3: `font-family: ${FONT_MONO}; font-size: 14px; font-weight: bold; color: ${lighten(color, 0.05)}; line-height: 1.6; margin: 24px 0 12px; opacity: 0.9;`,
blockquote: `margin: 22px 0; padding: 2px 0 2px 14px; border-left: 2px solid #3a3f47; background: none;`,
blockquoteP: `margin: 0; font-family: ${FONT_MONO}; font-size: 13px; line-height: 1.95; color: #7d8590; font-style: italic;`,
img: `max-width: 100%; border-radius: 8px; display: block; margin: 22px auto; border: 1px solid #2a2d34;`,
hr: `border: none; border-top: 1px dashed #33373f; margin: 30px 0;`,
strong: `font-weight: bold; color: ${lighten(color, 0.2)}; font-family: ${FONT_MONO};`,
}),
},
];

View File

@@ -0,0 +1,131 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, gradient, lighten, readableOn, rgba, stripes } from './helpers';
/**
* 节日促销组5 个):同为「闹」,五套身份各异——
* 促销=白底爆炸贴;新春=红纸金框;圣诞=雪点纸面;黑金=整篇黑金大促;樱花=粉雾花瓣
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-size: 15px; line-height: 1.8; color: #44403c; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 16px; font-size: 15px; line-height: 1.8; color: #44403c;`,
li: `margin: 5px 0; font-size: 15px; line-height: 1.8; color: #44403c;`,
...overrides,
});
export const festivalThemes: WechatTheme[] = [
{
key: 'festival-sale',
name: '节日·促销',
category: 'festival',
description: '斜纹警示条 + 虚线优惠券,电商大促的白底爆炸贴',
palette: ['#e60012', '#ff6b00', '#7b2d8b', '#0066cc', '#d4380d', '#c41d7f'],
styles: (color) =>
persona(color, {
h2: `display: inline-block; font-size: 17px; font-weight: 900; color: ${readableOn(color)}; line-height: 1.5; margin: 32px 0 16px; padding: 8px 18px; background: ${gradient(color, 100)}; border-radius: 6px; box-shadow: 0 4px 0 ${darken(color, 0.3)}; letter-spacing: 1px;`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${darken(color, 0.1)}; line-height: 1.5; margin: 24px 0 12px; padding: 2px 12px; border: 2px solid ${color}; border-radius: 999px;`,
blockquote: `margin: 22px 0; padding: 14px 16px; background: ${rgba(color, 0.06)}; border: 2px dashed ${color}; border-radius: 8px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.8; color: ${darken(color, 0.15)}; font-weight: bold;`,
img: `max-width: 100%; border-radius: 8px; display: block; margin: 20px auto; border: 3px solid ${color};`,
hr: `border: none; height: 10px; background: ${stripes(color, 0.55)}; border-radius: 5px; margin: 30px 0;`,
strong: `font-weight: 900; color: #ffffff; background: ${color}; padding: 0 8px; border-radius: 4px;`,
}),
},
{
key: 'festival-newyear',
name: '节日·新春',
category: 'festival',
description: '整篇红纸 + 鎏金双框 + 金字重点,开门见喜',
palette: ['#c8102e', '#b8860b', '#a72126', '#d4380d', '#9f1239', '#b45309'],
decor: {
h2: ['🧨 ', ' 🧧'],
},
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.9; color: #5c1a12; word-break: break-word; text-align: left; padding: 24px 16px; background: linear-gradient(180deg, ${rgba(color, 0.12)}, ${rgba(color, 0.05)} 120px); border: 2px solid #d4a017; outline: 1px solid ${rgba('#d4a017', 0.5)}; outline-offset: 4px; border-radius: 4px;`,
h2: `font-size: 18px; font-weight: 900; color: ${darken(color, 0.1)}; line-height: 1.6; margin: 32px 0 16px; text-align: center; letter-spacing: 3px;`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: #ffffff; line-height: 1.5; margin: 24px 0 12px; padding: 3px 14px; background: ${darken(color, 0.05)}; border-radius: 999px; letter-spacing: 2px;`,
blockquote: `margin: 22px 10px; padding: 14px 18px; background: rgba(255, 251, 240, 0.9); border: 1px solid #d4a017; border-radius: 8px; text-align: center;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: #7a4a12; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 8px; display: block; margin: 22px auto; border: 3px solid #d4a017; box-shadow: 0 4px 12px ${rgba(color, 0.3)};`,
hr: `border: none; height: 2px; background: linear-gradient(90deg, transparent, #d4a017, transparent); margin: 30px 0;`,
strong: `font-weight: 900; color: #9a6a00; letter-spacing: 1px;`,
}),
},
{
key: 'festival-xmas',
name: '节日·圣诞',
category: 'festival',
description: '拐杖糖条纹 + 缎带标题 + 白卡引用,冬日礼物感',
palette: ['#0f6b3d', '#c8102e', '#1d4ed8', '#b45309', '#0e7490', '#7c3aed'],
decor: {
h2: ['🎄 ', ''],
h3: ['❄ ', ''],
},
styles: (color) =>
persona(color, {
// 雪点纹理会压在正文下影响阅读(暗色下更是高对比噪点),改为干净的浅色纸面
container: `font-size: 15px; line-height: 1.85; color: #3f4a44; word-break: break-word; text-align: left; padding: 22px 15px; background: ${lighten(color, 0.94)}; border-radius: 12px; border: 1px solid ${rgba(color, 0.3)};`,
h2: `display: inline-block; font-size: 17px; font-weight: bold; color: #ffffff; line-height: 1.5; margin: 30px 0 16px; padding: 7px 18px; background: ${color}; border-radius: 8px 8px 8px 0; box-shadow: 0 4px 10px ${rgba(color, 0.35)};`,
h3: `font-size: 15px; font-weight: bold; color: ${darken(color, 0.15)}; line-height: 1.5; margin: 24px 0 12px;`,
blockquote: `margin: 22px 0; padding: 14px 16px; background: rgba(255, 255, 255, 0.85); border: none; border-left: 8px solid ${color}; border-radius: 8px; box-shadow: 0 2px 8px rgba(30, 60, 45, 0.08);`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: #5a6b62;`,
img: `max-width: 100%; border-radius: 12px; display: block; margin: 20px auto; border: 5px solid #ffffff; box-shadow: 0 4px 14px rgba(30, 60, 45, 0.16);`,
hr: `border: none; height: 8px; background: repeating-linear-gradient(45deg, ${color} 0 10px, #ffffff 10px 20px, ${rgba('#c8102e', 0.85)} 20px 30px, #ffffff 30px 40px); border-radius: 4px; margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.12)}; background: rgba(255, 255, 255, 0.9); padding: 0 6px; border-radius: 6px; border: 1px dashed ${rgba(color, 0.5)};`,
}),
},
{
key: 'festival-black-gold',
name: '节日·黑金',
category: 'festival',
description: '整篇曜黑 + 鎏金字与金线,高端大促的压场感',
palette: ['#d4a017', '#e6b422', '#c9a227', '#b8860b', '#d97706', '#eab308'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.9; color: #d8cfc0; word-break: break-word; text-align: left; padding: 24px 16px; background: #16130e; border: 1px solid ${rgba(color, 0.45)}; border-radius: 6px;`,
h1: `font-size: 22px; font-weight: 900; color: ${lighten(color, 0.2)}; line-height: 1.4; margin: 28px 0 18px; text-align: center; letter-spacing: 3px;`,
p: `margin: 0 0 16px; font-size: 15px; line-height: 1.9; color: #d8cfc0;`,
li: `margin: 5px 0; font-size: 15px; line-height: 1.9; color: #d8cfc0;`,
h2: `font-size: 18px; font-weight: 900; color: ${lighten(color, 0.15)}; line-height: 1.6; margin: 32px 0 16px; text-align: center; letter-spacing: 4px;`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: #16130e; line-height: 1.5; margin: 24px 0 12px; padding: 3px 14px; background: ${gradient(color, 100, lighten(color, 0.25))}; border-radius: 2px; letter-spacing: 2px;`,
blockquote: `margin: 22px 8px; padding: 14px 18px; background: rgba(255, 255, 255, 0.04); border-top: 1px solid ${rgba(color, 0.6)}; border-bottom: 1px solid ${rgba(color, 0.6)}; text-align: center;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: ${lighten(color, 0.3)}; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 4px; display: block; margin: 22px auto; border: 1px solid ${rgba(color, 0.55)}; padding: 5px; background: rgba(255, 255, 255, 0.03);`,
hr: `border: none; height: 1px; background: linear-gradient(90deg, transparent, ${color}, transparent); margin: 32px 0;`,
strong: `font-weight: bold; color: ${lighten(color, 0.2)};`,
em: `font-style: italic; color: #f5ead1;`,
a: `color: ${lighten(color, 0.15)}; text-decoration: none; border-bottom: 1px solid ${rgba(color, 0.5)};`,
code: `font-size: 13px; color: ${lighten(color, 0.25)}; background: rgba(255, 255, 255, 0.06); padding: 2px 6px; border-radius: 4px;`,
table: `border-collapse: collapse; width: 100%; margin: 0 0 16px; font-size: 14px;`,
th: `border: 1px solid ${rgba(color, 0.4)}; padding: 8px 12px; background: rgba(255, 255, 255, 0.06); color: ${lighten(color, 0.2)}; font-weight: bold; text-align: left;`,
td: `border: 1px solid rgba(216, 207, 192, 0.25); padding: 8px 12px; color: #d8cfc0;`,
}),
},
{
key: 'festival-sakura',
name: '节日·樱花',
category: 'festival',
description: '粉雾纸面 + 丝带引用,春日限定',
palette: ['#e75480', '#f472b6', '#db2777', '#c2557f', '#e11d48', '#d946ef'],
decor: {
h2: ['🌸 ', ''],
},
styles: (color) =>
persona(color, {
// 花瓣圆点纹理压正文影响阅读,去掉后靠粉雾纸色 + 白卡元素表达春日感
container: `font-size: 15px; line-height: 1.9; color: #5c4a52; word-break: break-word; text-align: left; padding: 22px 15px; background: ${lighten(color, 0.93)}; border-radius: 16px;`,
h2: `font-size: 17px; font-weight: bold; color: ${darken(color, 0.15)}; line-height: 1.6; margin: 30px 0 16px; text-align: center; letter-spacing: 2px;`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${darken(color, 0.18)}; line-height: 1.5; margin: 24px 0 12px; padding: 3px 14px; background: rgba(255, 255, 255, 0.85); border-radius: 999px; box-shadow: 0 2px 6px ${rgba(color, 0.2)};`,
blockquote: `margin: 22px 8px; padding: 13px 18px; background: rgba(255, 255, 255, 0.8); border: none; border-radius: 4px 20px 4px 20px; box-shadow: 0 2px 10px ${rgba(color, 0.16)};`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: ${darken(color, 0.22)};`,
img: `max-width: 100%; border-radius: 18px; display: block; margin: 20px auto; border: 5px solid rgba(255, 255, 255, 0.9); box-shadow: 0 4px 14px ${rgba(color, 0.25)};`,
hr: `border: none; border-top: 3px dotted ${rgba(color, 0.45)}; width: 170px; margin: 30px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.12)}; background: rgba(255, 255, 255, 0.9); padding: 0 6px; border-radius: 999px;`,
}),
},
];

View File

@@ -0,0 +1,113 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, gradient, lighten, rgba } from './helpers';
/**
* 渐变卡片组5 个):同为「渐变」,五套身份各异——
* 极光=白底光带;落日=整篇暖霞渐变纸;海洋=顶部下沉水色;玻璃=全文一张玻璃卡;标签=渐变书签徽章
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-size: 15px; line-height: 1.85; color: #3d4351; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 17px; font-size: 15px; line-height: 1.85; color: #3d4351;`,
li: `margin: 5px 0; font-size: 15px; line-height: 1.85; color: #3d4351;`,
...overrides,
});
export const gradientThemes: WechatTheme[] = [
{
key: 'grad-aurora',
name: '渐变·极光',
category: 'gradient',
description: '白底上流动的极光条:标题、分割、图片影子全是光',
palette: ['#7c3aed', '#0ea5e9', '#06b6d4', '#8b5cf6', '#ec4899', '#10b981'],
styles: (color) =>
persona(color, {
h2: `display: inline-block; font-size: 18px; font-weight: 800; color: #1f2430; line-height: 1.5; margin: 32px 0 16px; padding-bottom: 8px; background: linear-gradient(90deg, ${color}, ${lighten(color, 0.45)}) left bottom no-repeat; background-size: 100% 5px;`,
h3: `font-size: 16px; font-weight: bold; color: ${darken(color, 0.15)}; line-height: 1.5; margin: 24px 0 12px;`,
blockquote: `margin: 22px 0; padding: 14px 18px; background: linear-gradient(120deg, ${rgba(color, 0.12)}, ${rgba(color, 0.03)} 70%); border: none; border-radius: 12px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: ${darken(color, 0.3)};`,
img: `max-width: 100%; border-radius: 14px; display: block; margin: 22px auto; box-shadow: 0 10px 30px ${rgba(color, 0.28)};`,
hr: `border: none; height: 3px; background: linear-gradient(90deg, transparent, ${color}, ${lighten(color, 0.4)}, transparent); border-radius: 2px; margin: 32px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.15)};`,
}),
},
{
key: 'grad-sunset',
name: '渐变·落日',
category: 'gradient',
description: '整篇从霞色到白的渐变纸,白卡引用浮在暮色上',
palette: ['#f97316', '#ef4444', '#f59e0b', '#ec4899', '#e11d48', '#d97706'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.85; color: #4a4038; word-break: break-word; text-align: left; padding: 24px 16px; background: linear-gradient(180deg, ${rgba(color, 0.18)}, ${rgba(color, 0.06)} 200px, #ffffff 420px); border-radius: 14px;`,
h2: `font-size: 18px; font-weight: 800; color: ${darken(color, 0.25)}; line-height: 1.5; margin: 32px 0 16px; text-align: center; letter-spacing: 1px;`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: #ffffff; line-height: 1.5; margin: 24px 0 12px; padding: 4px 16px; background: ${gradient(color, 100)}; border-radius: 999px 999px 999px 0;`,
blockquote: `margin: 22px 6px; padding: 14px 18px; background: rgba(255, 255, 255, 0.9); border: none; border-radius: 14px; box-shadow: 0 4px 16px ${rgba(color, 0.18)};`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: #7a6a5c;`,
img: `max-width: 100%; border-radius: 14px; display: block; margin: 22px auto; border: 5px solid rgba(255, 255, 255, 0.9); box-shadow: 0 6px 20px ${rgba(color, 0.25)};`,
hr: `border: none; height: 2px; background: linear-gradient(90deg, ${rgba(color, 0.7)}, transparent); margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.2)}; background: linear-gradient(to top, ${rgba(color, 0.28)} 36%, transparent 36%); padding: 0 2px;`,
}),
},
{
key: 'grad-ocean',
name: '渐变·海洋',
category: 'gradient',
description: '水色从顶部下沉,圆角波浪引用像一朵浪',
palette: ['#0891b2', '#0ea5e9', '#0d9488', '#3b82f6', '#06b6d4', '#4f46e5'],
styles: (color) =>
persona(color, {
// 顶部实色条去掉,海洋感由顶部下沉的水色渐变本身表达
container: `font-size: 15px; line-height: 1.85; color: #35485a; word-break: break-word; text-align: left; padding: 22px 15px; background: linear-gradient(180deg, ${rgba(color, 0.14)} 0, transparent 160px); border-radius: 14px;`,
h2: `display: inline-block; font-size: 17px; font-weight: 800; color: ${darken(color, 0.22)}; line-height: 1.5; margin: 32px 0 16px; padding: 6px 16px; background: rgba(255, 255, 255, 0.85); border-radius: 999px; box-shadow: 0 3px 10px ${rgba(color, 0.2)};`,
h3: `font-size: 15px; font-weight: bold; color: ${darken(color, 0.18)}; line-height: 1.5; margin: 24px 0 12px; padding-left: 12px; border-left: 4px solid ${rgba(color, 0.6)};`,
blockquote: `margin: 22px 0; padding: 14px 18px; background: ${rgba(color, 0.08)}; border: none; border-radius: 24px 24px 24px 4px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: ${darken(color, 0.28)};`,
img: `max-width: 100%; border-radius: 16px; display: block; margin: 22px auto; box-shadow: 0 8px 24px ${rgba(color, 0.24)};`,
hr: `border: none; height: 4px; background: radial-gradient(circle, ${rgba(color, 0.55)} 2px, transparent 2px); background-size: 14px 4px; margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.18)};`,
}),
},
{
key: 'grad-glass',
name: '渐变·玻璃',
category: 'gradient',
description: '全文一张磨砂玻璃卡:渐变底、白雾层、细亮边',
palette: ['#8b5cf6', '#06b6d4', '#ec4899', '#10b981', '#f59e0b', '#3b82f6'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.85; color: #3a3f4e; word-break: break-word; text-align: left; padding: 26px 18px; background: linear-gradient(135deg, ${rgba(color, 0.16)}, rgba(255, 255, 255, 0.9) 45%, ${rgba(color, 0.1)}); border: 1px solid rgba(255, 255, 255, 0.9); outline: 1px solid ${rgba(color, 0.3)}; border-radius: 20px; box-shadow: 0 10px 30px ${rgba(color, 0.18)};`,
h2: `display: inline-block; font-size: 17px; font-weight: 800; color: ${darken(color, 0.2)}; line-height: 1.5; margin: 32px 0 16px; padding: 6px 18px; background: rgba(255, 255, 255, 0.75); border: 1px solid rgba(255, 255, 255, 0.95); border-radius: 14px; box-shadow: 0 4px 12px ${rgba(color, 0.16)};`,
h3: `font-size: 15px; font-weight: bold; color: ${darken(color, 0.16)}; line-height: 1.5; margin: 24px 0 12px;`,
blockquote: `margin: 22px 0; padding: 14px 18px; background: rgba(255, 255, 255, 0.65); border: 1px solid rgba(255, 255, 255, 0.9); border-radius: 14px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: #5d6472;`,
img: `max-width: 100%; border-radius: 16px; display: block; margin: 22px auto; border: 1px solid rgba(255, 255, 255, 0.95); box-shadow: 0 8px 24px ${rgba(color, 0.22)};`,
hr: `border: none; height: 1px; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.98), ${rgba(color, 0.5)}, rgba(255, 255, 255, 0.98), transparent); margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.18)};`,
}),
},
{
key: 'grad-tag',
name: '渐变·标签',
category: 'gradient',
description: '渐变书签从左侧插进来,缎带引用带折角',
palette: ['#ec4899', '#8b5cf6', '#f97316', '#06b6d4', '#10b981', '#6366f1'],
numbering: true,
numberStyle: (color) =>
`display: inline-block; padding: 3px 12px 3px 10px; background: ${gradient(color, 100)}; color: #ffffff; border-radius: 0 999px 999px 0; font-size: 13px; font-weight: bold; margin-right: 10px; vertical-align: 2px;`,
styles: (color) =>
persona(color, {
h2: `font-size: 17px; font-weight: 800; color: #262b36; line-height: 1.6; margin: 32px 0 16px; padding: 8px 14px 8px 0; border-radius: 0 999px 999px 0; background: linear-gradient(90deg, ${rgba(color, 0.16)}, transparent 75%); border-left: 6px solid ${color};`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${darken(color, 0.2)}; line-height: 1.5; margin: 24px 0 12px; padding: 2px 12px; background: ${rgba(color, 0.1)}; border-radius: 6px 6px 6px 0; border-bottom: 2px solid ${color};`,
blockquote: `margin: 22px 0 22px 10px; padding: 14px 18px; background: linear-gradient(100deg, ${rgba(color, 0.1)}, ${rgba(color, 0.03)}); border-left: 4px solid ${color}; border-radius: 0 14px 14px 0;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: ${darken(color, 0.3)};`,
img: `max-width: 100%; border-radius: 12px; display: block; margin: 22px auto; border-left: 6px solid ${color}; box-shadow: 6px 6px 18px ${rgba(color, 0.2)};`,
hr: `border: none; height: 3px; background: linear-gradient(90deg, ${color}, transparent 65%); border-radius: 2px; margin: 30px 0;`,
strong: `font-weight: bold; color: #ffffff; background: ${gradient(color, 100)}; padding: 0 7px; border-radius: 999px;`,
}),
},
];

View File

@@ -0,0 +1,116 @@
import type { ThemeStyleSheet } from './types';
/**
* 颜色工具 + 基础样式工厂
* 主题换色的原理:所有和「主色」相关的样式都通过这里的函数从一个 hex 值派生
* (透明度、加深、变浅、可读文字色),保证任意颜色下排版依旧协调
*/
/** hex → [r, g, b],支持 #abc / #aabbcc */
export function hexToRgb(hex: string): [number, number, number] {
let value = hex.replace('#', '').trim();
if (value.length === 3) {
value = value
.split('')
.map((c) => c + c)
.join('');
}
const num = Number.parseInt(value.slice(0, 6), 16);
if (Number.isNaN(num)) {
return [51, 51, 51];
}
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}
/** 主色带透明度rgba(r,g,b,alpha),用于浅色底/浅色边框 */
export function rgba(hex: string, alpha: number): string {
const [r, g, b] = hexToRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/** 与目标色按权重混合weight=混入比例 0~1darken/lighten 的底层实现 */
function mixChannel(from: number, to: number, weight: number): number {
return Math.round(from + (to - from) * weight);
}
/** 加深主色(混入黑色),用于 hover 边/深色文字 */
export function darken(hex: string, amount: number): string {
const [r, g, b] = hexToRgb(hex);
return `rgb(${mixChannel(r, 0, amount)}, ${mixChannel(g, 0, amount)}, ${mixChannel(b, 0, amount)})`;
}
/** 变浅主色(混入白色),用于浅色块 */
export function lighten(hex: string, amount: number): string {
const [r, g, b] = hexToRgb(hex);
return `rgb(${mixChannel(r, 255, amount)}, ${mixChannel(g, 255, amount)}, ${mixChannel(b, 255, amount)})`;
}
/** 根据主色亮度返回可读的前景色(深色底配白字、浅色底配深字) */
export function readableOn(hex: string): string {
const [r, g, b] = hexToRgb(hex);
// 相对亮度Rec. 709 加权),阈值 0.6 偏保守,保证公众号正文可读
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luminance > 0.6 ? '#333333' : '#ffffff';
}
/** 主色双色渐变to 默认为主色加深 25%),标题条/徽章底用 */
export function gradient(hex: string, deg = 135, to?: string): string {
return `linear-gradient(${deg}deg, ${hex}, ${to ?? darken(hex, 0.25)})`;
}
// 注意:不要提供「容器满铺底纹」类工具(波点/网格等),纹理压在正文下会影响阅读
/** 斜纹纹理(分割线/警示条等装饰元素用禁止铺正文底repeating-linear-gradient 内联可用 */
export function stripes(hex: string, alpha = 0.16): string {
return `repeating-linear-gradient(45deg, ${rgba(hex, alpha)} 0 8px, transparent 8px 16px)`;
}
/** 主色柔和投影(贴纸/卡片悬浮感) */
export function softShadow(hex: string, alpha = 0.28): string {
return `box-shadow: 0 4px 12px ${rgba(hex, alpha)};`;
}
/** 公众号安全字体栈:苹方/微软雅黑优先,导出后各端可用 */
export const FONT_SANS =
"-apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif";
/** 衬线字体栈:文艺/中国风主题用 */
export const FONT_SERIF =
"Optima, 'Songti SC', STSong, 'Noto Serif SC', SimSun, serif";
/** 等宽字体栈:代码/终端风主题用 */
export const FONT_MONO =
"'SF Mono', Menlo, Consolas, 'Courier New', monospace";
/**
* 基础样式工厂:给出一套「正文安全默认值」,各主题只需覆盖有个性的元素
* h1/h2/h3/blockquote/hr/strong 是风格差异的主要载体)
*/
export function baseStyles(
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet {
const base: ThemeStyleSheet = {
container: `font-family: ${FONT_SANS}; font-size: 15px; line-height: 1.8; color: #3f3f3f; word-break: break-word; text-align: left; padding: 4px 0;`,
h1: `font-size: 22px; font-weight: bold; color: #262626; line-height: 1.4; margin: 28px 0 18px; text-align: center;`,
h2: `font-size: 18px; font-weight: bold; color: #262626; line-height: 1.5; margin: 32px 0 16px;`,
h3: `font-size: 16px; font-weight: bold; color: #404040; line-height: 1.5; margin: 24px 0 12px;`,
p: `margin: 0 0 16px; font-size: 15px; line-height: 1.8; color: #3f3f3f;`,
blockquote: `margin: 20px 0; padding: 12px 16px; border-left: 4px solid ${color}; background: ${rgba(color, 0.06)}; border-radius: 0 6px 6px 0;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.8; color: #666666;`,
ul: `margin: 0 0 16px; padding-left: 24px; list-style-type: disc;`,
ol: `margin: 0 0 16px; padding-left: 24px; list-style-type: decimal;`,
li: `margin: 4px 0; font-size: 15px; line-height: 1.8; color: #3f3f3f;`,
strong: `font-weight: bold; color: ${color};`,
em: `font-style: italic;`,
a: `color: ${color}; text-decoration: none; border-bottom: 1px solid ${rgba(color, 0.4)};`,
hr: `border: none; border-top: 1px solid #e5e5e5; margin: 28px 0;`,
img: `max-width: 100%; border-radius: 6px; display: block; margin: 16px auto;`,
code: `font-family: ${FONT_MONO}; font-size: 13px; color: ${darken(color, 0.15)}; background: ${rgba(color, 0.08)}; padding: 2px 6px; border-radius: 4px;`,
pre: `background: #2d2d2d; border-radius: 8px; padding: 14px 16px; overflow-x: auto; margin: 0 0 16px;`,
preCode: `font-family: ${FONT_MONO}; font-size: 13px; line-height: 1.6; color: #e8e8e8; background: none; padding: 0; display: block; white-space: pre-wrap;`,
table: `border-collapse: collapse; width: 100%; margin: 0 0 16px; font-size: 14px;`,
th: `border: 1px solid #e5e5e5; padding: 8px 12px; background: ${rgba(color, 0.08)}; color: #262626; font-weight: bold; text-align: left;`,
td: `border: 1px solid #e5e5e5; padding: 8px 12px; color: #3f3f3f;`,
};
return { ...base, ...overrides };
}

View File

@@ -0,0 +1,71 @@
import type { ThemeCategoryMeta, WechatTheme } from './types';
import { businessThemes } from './business';
import { getCustomTheme } from './custom';
import { chineseThemes } from './chinese';
import { cuteThemes } from './cute';
import { darkThemes } from './darkmode';
import { festivalThemes } from './festival';
import { gradientThemes } from './gradientcard';
import { literaryThemes } from './literary';
import { magazineThemes } from './magazine';
import { minimalThemes } from './minimal';
import { retroThemes } from './retro';
import { techThemes } from './tech';
import { vividThemes } from './vivid';
export type { ThemeCategory, ThemeStyleSheet, WechatTheme } from './types';
export {
CUSTOM_CATEGORY_KEY,
customIdFromKey,
customThemeList,
isCustomThemeKey,
} from './custom';
/** 主题分类元信息(面板分组展示顺序,冲击力强的靠前露出) */
export const THEME_CATEGORIES: ThemeCategoryMeta[] = [
{ key: 'gradient', name: '渐变' },
{ key: 'minimal', name: '极简' },
{ key: 'business', name: '商务' },
{ key: 'tech', name: '科技' },
{ key: 'cute', name: '手账' },
{ key: 'literary', name: '文艺' },
{ key: 'vivid', name: '活泼' },
{ key: 'magazine', name: '杂志' },
{ key: 'retro', name: '复古' },
{ key: 'dark', name: '暗黑' },
{ key: 'chinese', name: '中国风' },
{ key: 'festival', name: '节日' },
];
/** 全部 60 套主题12 组 × 5 个),顺序与分类一致 */
export const ALL_THEMES: WechatTheme[] = [
...gradientThemes,
...minimalThemes,
...businessThemes,
...techThemes,
...cuteThemes,
...literaryThemes,
...vividThemes,
...magazineThemes,
...retroThemes,
...darkThemes,
...chineseThemes,
...festivalThemes,
];
/** 新文章的默认主题 */
export const DEFAULT_THEME_KEY = 'minimal-left-line';
/**
* 按 key 查找主题:自定义主题(我的模板)优先,再查内置 60 套;
* 找不到(如旧数据的主题被移除/删除了自定义模板)时回退默认主题,保证渲染不崩。
* 注意:自定义注册表是 reactive 的,在 computed 里调用本函数会在列表异步加载后自动重渲染
*/
export function getTheme(key: string): WechatTheme {
return (
getCustomTheme(key) ??
ALL_THEMES.find((theme) => theme.key === key) ??
(ALL_THEMES.find((theme) => theme.key === DEFAULT_THEME_KEY) as WechatTheme)
);
}

View File

@@ -0,0 +1,128 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, FONT_SERIF, rgba } from './helpers';
/**
* 文艺组5 个):同为「纸」,五套身份各异——
* 引号=米白书页大引号;破折=素白散文诗;顿点=淡墨晕染;信笺=横线信纸;诗行=全居中诗集
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2.05; color: #4a453d; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 20px; font-size: 15px; line-height: 2.05; color: #4a453d;`,
li: `margin: 6px 0; font-size: 15px; line-height: 2.05; color: #4a453d;`,
em: `font-style: italic; color: ${darken(color, 0.1)};`,
a: `color: ${darken(color, 0.12)}; text-decoration: none; border-bottom: 1px solid ${rgba(color, 0.4)};`,
...overrides,
});
export const literaryThemes: WechatTheme[] = [
{
key: 'literary-quote',
name: '文艺·引号',
category: 'literary',
description: '米白书页 + 超大衬线引号,像一本翻开的散文集',
palette: ['#8c6239', '#4a6741', '#6b5b95', '#a0522d', '#456268', '#8e5b5b'],
decor: {
h2: ['❝ ', ''],
},
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2.05; color: #4a453d; word-break: break-word; text-align: left; padding: 22px 16px; background: #faf8f2; border-radius: 6px;`,
h2: `font-family: ${FONT_SERIF}; font-size: 19px; font-weight: bold; color: #3d3831; line-height: 1.7; margin: 34px 0 16px;`,
h3: `font-family: ${FONT_SERIF}; font-size: 16px; font-weight: bold; color: ${darken(color, 0.15)}; line-height: 1.6; margin: 24px 0 12px;`,
blockquote: `margin: 24px 20px; padding: 0; border: none; background: none; text-align: center;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2.05; color: ${darken(color, 0.12)}; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 22px auto; padding: 6px; background: #ffffff; border: 1px solid #e8e2d4; box-shadow: 0 2px 10px rgba(90, 80, 60, 0.08);`,
hr: `border: none; border-top: 1px solid #ddd5c4; width: 140px; margin: 32px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.18)};`,
}),
},
{
key: 'literary-dash',
name: '文艺·破折',
category: 'literary',
description: '素白纸面 + 大字距细瘦排版,一首安静的散文诗',
palette: ['#4a6741', '#8c6239', '#456268', '#6b5b95', '#7a6a53', '#8e5b5b'],
decor: {
h2: ['—— ', ''],
},
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2.15; color: #55503f; word-break: break-word; text-align: left; padding: 4px 0; letter-spacing: 1px;`,
p: `margin: 0 0 24px; font-size: 15px; line-height: 2.15; color: #55503f;`,
li: `margin: 8px 0; font-size: 15px; line-height: 2.15; color: #55503f;`,
h2: `font-family: ${FONT_SERIF}; font-size: 18px; font-weight: bold; color: #3d3831; line-height: 1.7; margin: 36px 0 18px; letter-spacing: 2px;`,
h3: `font-family: ${FONT_SERIF}; font-size: 16px; font-weight: bold; color: #55503f; line-height: 1.6; margin: 26px 0 12px; letter-spacing: 2px;`,
blockquote: `margin: 26px 12px; padding: 0 0 0 16px; border-left: 1px solid ${rgba(color, 0.5)}; background: none;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 2.15; color: #8f887a; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 26px auto;`,
hr: `border: none; border-top: 1px solid #e3ded2; width: 48px; margin: 40px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.15)}; letter-spacing: 1px;`,
}),
},
{
key: 'literary-ink',
name: '文艺·顿点',
category: 'literary',
description: '淡墨晕染:顿点标题、墨色引用块、笔触重点',
palette: ['#6b5b95', '#8c6239', '#4a6741', '#456268', '#a0522d', '#5b5b8e'],
decor: {
h2: ['、', ''],
},
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2.05; color: #46423a; word-break: break-word; text-align: left; padding: 20px 15px; background: #f6f5f1; border-radius: 4px;`,
h2: `display: inline-block; font-family: ${FONT_SERIF}; font-size: 18px; font-weight: bold; color: #3d3831; line-height: 1.7; margin: 32px 0 16px; background: linear-gradient(to top, ${rgba(color, 0.18)} 30%, transparent 30%); padding: 0 6px;`,
h3: `font-family: ${FONT_SERIF}; font-size: 16px; font-weight: bold; color: #55503f; line-height: 1.6; margin: 24px 0 12px;`,
blockquote: `margin: 22px 8px; padding: 12px 16px; background: ${rgba(color, 0.08)}; border: none; border-radius: 2px 14px 2px 14px;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: ${darken(color, 0.15)};`,
img: `max-width: 100%; border-radius: 4px; display: block; margin: 22px auto; box-shadow: 4px 4px 0 ${rgba(color, 0.18)};`,
hr: `border: none; border-top: 3px dotted ${rgba(color, 0.4)}; width: 90px; margin: 34px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.12)}; background: linear-gradient(to top, ${rgba(color, 0.22)} 32%, transparent 32%); padding: 0 2px;`,
}),
},
{
key: 'literary-letter',
name: '文艺·信笺',
category: 'literary',
description: '米白信笺 + 微微倾斜的贴照,一封写给读者的长信',
palette: ['#8e5b5b', '#456268', '#8c6239', '#4a6741', '#6b5b95', '#7a6a53'],
styles: (color) =>
persona(color, {
// 横线底纹压正文影响阅读,信笺感由米白纸色 + 虚线引用卡 + 歪贴照片表达
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2.05; color: #4a453d; word-break: break-word; text-align: left; padding: 24px 18px; background: #fdfbf5; border: 1px solid #eae2cf; border-radius: 4px;`,
h2: `display: inline-block; font-family: ${FONT_SERIF}; font-size: 18px; font-weight: bold; color: ${darken(color, 0.12)}; line-height: 1.7; margin: 34px 0 16px; padding-bottom: 4px; border-bottom: 1px dashed ${rgba(color, 0.5)};`,
h3: `font-family: ${FONT_SERIF}; font-size: 16px; font-weight: bold; color: #55503f; line-height: 1.6; margin: 24px 0 12px;`,
blockquote: `margin: 22px 8px; padding: 12px 16px; background: rgba(255, 255, 255, 0.75); border: 1px dashed ${rgba(color, 0.45)}; border-radius: 8px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 2; color: ${darken(color, 0.12)};`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 24px auto; padding: 6px 6px 20px; background: #ffffff; border: 1px solid #e8e0cc; transform: rotate(-1deg); box-shadow: 0 4px 14px rgba(90, 80, 60, 0.16);`,
hr: `border: none; border-top: 1px dashed #d9cfb8; margin: 32px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.15)}; border-bottom: 2px dotted ${rgba(color, 0.55)};`,
}),
},
{
key: 'literary-poem',
name: '文艺·诗行',
category: 'literary',
description: '全部居中 + 五字距标题,一页竖排感的诗集',
palette: ['#456268', '#8c6239', '#6b5b95', '#4a6741', '#8e5b5b', '#5f5f5f'],
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2.2; color: #4a453d; word-break: break-word; text-align: center; padding: 28px 18px; background: #fbfaf6; border-top: 3px double ${rgba(color, 0.4)}; border-bottom: 3px double ${rgba(color, 0.4)};`,
p: `margin: 0 0 22px; font-size: 15px; line-height: 2.2; color: #4a453d;`,
li: `margin: 8px 0; font-size: 15px; line-height: 2.2; color: #4a453d;`,
h1: `font-family: ${FONT_SERIF}; font-size: 21px; font-weight: bold; color: #3d3831; line-height: 1.7; margin: 30px 0 22px; letter-spacing: 4px;`,
h2: `font-family: ${FONT_SERIF}; font-size: 17px; font-weight: bold; color: ${darken(color, 0.12)}; line-height: 1.7; margin: 36px 0 18px; letter-spacing: 5px;`,
h3: `font-family: ${FONT_SERIF}; font-size: 15px; font-weight: bold; color: #55503f; line-height: 1.6; margin: 26px 0 14px; letter-spacing: 3px;`,
blockquote: `margin: 26px 24px; padding: 0; border: none; background: none;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 2.2; color: #8f887a; letter-spacing: 2px;`,
img: `max-width: 86%; border-radius: 2px; display: block; margin: 24px auto; border: 1px solid #e5dfd0; padding: 5px; background: #ffffff;`,
hr: `border: none; border-top: 1px solid ${rgba(color, 0.35)}; width: 60px; margin: 34px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.15)};`,
}),
},
];

View File

@@ -0,0 +1,41 @@
/**
* 自定义主题加载器:从后端拉「我的模板」列表并注册到运行时注册表
*
* 为什么单独一个文件themes/custom.ts 只放纯逻辑(注册表/清洗/构建),
* 不 import 请求层,保证主题引擎可以脱离 HTTP 环境做冒烟测试;
* 网络编排(去重的懒加载 + 强制刷新)收敛在这里。
*/
import { getWechatThemeList } from '../../api';
import { setCustomThemes } from './custom';
/** 是否已成功加载过(成功一次后不再重复请求,导入/删除后传 force 刷新) */
let loaded = false;
/** 进行中的请求(并发调用去重:编辑器/发布弹窗/版本抽屉可能同时触发) */
let pending: null | Promise<void> = null;
/**
* 确保自定义主题已加载:多处调用只发一次请求;失败静默(编辑器仍可用内置主题)
* @param force 导入/删除模板后传 true 强制重新拉取
*/
export async function ensureCustomThemesLoaded(force = false): Promise<void> {
if (force) {
loaded = false;
}
if (loaded) {
return;
}
if (!pending) {
pending = getWechatThemeList()
.then((rows) => {
setCustomThemes(Array.isArray(rows) ? rows : []);
loaded = true;
})
.catch(() => {
// 拉取失败不打断编辑器:自定义主题缺席时 getTheme 会回退默认主题
})
.finally(() => {
pending = null;
});
}
return pending;
}

View File

@@ -0,0 +1,115 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, FONT_SERIF, rgba } from './helpers';
/**
* 杂志组5 个):同为「刊」,五套身份各异——
* 大黑体=巨字黑白刊;编号方块=黑框印刷版面;硬阴影=波普漫画;上分割=专栏页;字距=国际刊灰调
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-size: 15px; line-height: 1.85; color: #262626; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 18px; font-size: 15px; line-height: 1.85; color: #262626;`,
li: `margin: 6px 0; font-size: 15px; line-height: 1.85; color: #262626;`,
...overrides,
});
export const magazineThemes: WechatTheme[] = [
{
key: 'magazine-black',
name: '杂志·大黑体',
category: 'magazine',
description: '22px 巨号黑体 + 通栏图,黑白时尚刊的冲击力',
palette: ['#111111', '#c8102e', '#0033a0', '#e87722', '#00685e', '#5c068c'],
styles: (color) =>
persona(color, {
h1: `font-size: 26px; font-weight: 900; color: #111111; line-height: 1.3; margin: 30px 0 20px; letter-spacing: -0.5px;`,
h2: `font-size: 22px; font-weight: 900; color: #111111; line-height: 1.35; margin: 36px 0 18px; letter-spacing: -0.5px; border-bottom: 4px solid ${color}; padding-bottom: 10px;`,
h3: `font-size: 17px; font-weight: 800; color: #111111; line-height: 1.4; margin: 26px 0 12px;`,
blockquote: `margin: 26px 0; padding: 4px 0 4px 18px; border-left: 6px solid #111111; background: none;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 17px; line-height: 1.7; color: #111111; font-weight: bold;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 24px auto;`,
hr: `border: none; border-top: 4px solid #111111; margin: 34px 0;`,
strong: `font-weight: 900; color: ${color === '#111111' ? '#c8102e' : color};`,
em: `font-style: italic; font-family: ${FONT_SERIF};`,
}),
},
{
key: 'magazine-number-block',
name: '杂志·编号方块',
category: 'magazine',
description: '整篇套黑色印刷框,方块编号像印上去的版号',
palette: ['#c8102e', '#111111', '#0033a0', '#00685e', '#e87722', '#5c068c'],
numbering: true,
numberStyle: (color) =>
`display: inline-block; min-width: 30px; padding: 4px 8px; background: ${color}; color: #ffffff; font-size: 15px; font-weight: 900; margin-right: 12px; text-align: center;`,
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.85; color: #262626; word-break: break-word; text-align: left; padding: 22px 16px; border: 2px solid #111111;`,
h2: `font-size: 19px; font-weight: 900; color: #111111; line-height: 1.4; margin: 34px 0 16px;`,
h3: `display: inline-block; font-size: 15px; font-weight: 800; color: #ffffff; line-height: 1.5; margin: 24px 0 12px; padding: 2px 10px; background: #111111;`,
blockquote: `margin: 24px 0; padding: 14px 16px; border: 1px solid #111111; background: none;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: #444444;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 22px auto; border: 1px solid #111111;`,
hr: `border: none; border-top: 1px solid #111111; margin: 32px 0;`,
strong: `font-weight: 900; color: ${darken(color, 0.05)};`,
}),
},
{
key: 'magazine-hard-shadow',
name: '杂志·硬阴影',
category: 'magazine',
description: '波普漫画:主色纸面 + 黑描边硬阴影的标题与图片',
palette: ['#e87722', '#c8102e', '#0033a0', '#00685e', '#5c068c', '#b7950b'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.85; color: #262626; word-break: break-word; text-align: left; padding: 20px 14px; background: ${rgba(color, 0.07)};`,
h2: `display: inline-block; font-size: 18px; font-weight: 900; color: #111111; line-height: 1.5; margin: 32px 0 18px; padding: 7px 16px; background: #ffffff; border: 2px solid #111111; box-shadow: 6px 6px 0 ${color};`,
h3: `display: inline-block; font-size: 15px; font-weight: 800; color: #111111; line-height: 1.5; margin: 24px 0 12px; padding: 3px 10px; background: ${rgba(color, 0.25)}; border: 2px solid #111111;`,
blockquote: `margin: 24px 6px 24px 0; padding: 14px 16px; background: #ffffff; border: 2px solid #111111; box-shadow: 6px 6px 0 ${rgba(color, 0.6)};`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: #262626;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 24px auto; border: 2px solid #111111; box-shadow: 8px 8px 0 ${color};`,
hr: `border: none; height: 8px; background: repeating-linear-gradient(-45deg, #111111 0 10px, transparent 10px 20px); margin: 32px 0;`,
strong: `font-weight: 900; color: #111111; background: ${rgba(color, 0.4)}; padding: 0 4px;`,
}),
},
{
key: 'magazine-topline',
name: '杂志·上分割',
category: 'magazine',
description: '粗色顶线 + 荧光笔重点,报纸专栏页的层次',
palette: ['#0033a0', '#c8102e', '#00685e', '#111111', '#e87722', '#5c068c'],
styles: (color) =>
persona(color, {
h2: `font-size: 18px; font-weight: 900; color: #111111; line-height: 1.5; margin: 34px 0 16px; padding-top: 12px; border-top: 6px solid ${color};`,
h3: `font-size: 15px; font-weight: 800; color: ${darken(color, 0.1)}; line-height: 1.5; margin: 24px 0 12px; letter-spacing: 1px;`,
blockquote: `margin: 24px 0; padding: 12px 0 12px 18px; border-left: 1px solid #111111; background: none;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 15px; line-height: 1.9; color: #555555; font-style: italic;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 22px auto; border-bottom: 6px solid ${color};`,
hr: `border: none; border-top: 6px solid ${rgba(color, 0.9)}; width: 64px; margin: 34px 0;`,
strong: `font-weight: 900; color: #111111; background: linear-gradient(to top, ${rgba(color, 0.3)} 45%, transparent 45%); padding: 0 2px;`,
}),
},
{
key: 'magazine-caps',
name: '杂志·字距',
category: 'magazine',
description: '灰调纸面 + 居中大字距 + 灰白照片,国际刊的克制',
palette: ['#5c068c', '#111111', '#0033a0', '#00685e', '#c8102e', '#8a6d3b'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.95; color: #3a3a3a; word-break: break-word; text-align: left; padding: 24px 16px; background: #f4f4f2; border-top: 1px solid #111111; border-bottom: 1px solid #111111;`,
h1: `font-size: 22px; font-weight: bold; color: #111111; line-height: 1.45; margin: 28px 0 18px; text-align: center; letter-spacing: 6px;`,
h2: `font-size: 16px; font-weight: bold; color: #111111; line-height: 1.6; margin: 36px 0 18px; text-align: center; letter-spacing: 6px;`,
h3: `font-size: 14px; font-weight: bold; color: ${darken(color, 0.05)}; line-height: 1.5; margin: 26px 0 12px; text-align: center; letter-spacing: 4px;`,
blockquote: `margin: 26px 22px; padding: 14px 0; border-top: 1px solid #bbbbbb; border-bottom: 1px solid #bbbbbb; background: none; text-align: center;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: #666666; letter-spacing: 2px;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 24px auto; filter: grayscale(35%); border: 8px solid #ffffff; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);`,
hr: `border: none; border-top: 1px solid #bbbbbb; width: 40px; margin: 36px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.05)}; letter-spacing: 1px;`,
}),
},
];

View File

@@ -0,0 +1,125 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, FONT_SERIF, rgba } from './helpers';
/**
* 极简组5 个):同为「轻」,但五套各有身份,不只是换标题——
* 竖线=经典白纸;底线=全线条系统;色点=浅灰卡片笔记;幽灵编号=超大留白编辑部;衬线居中=书卷素白
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-size: 15px; line-height: 1.9; color: #3f3f3f; word-break: break-word; text-align: left; padding: 4px 0; letter-spacing: 0.3px;`,
p: `margin: 0 0 18px; font-size: 15px; line-height: 1.9; color: #3f3f3f;`,
li: `margin: 6px 0; font-size: 15px; line-height: 1.9; color: #3f3f3f;`,
...overrides,
});
export const minimalThemes: WechatTheme[] = [
{
key: 'minimal-left-line',
name: '简·竖线',
category: 'minimal',
description: '经典白纸 + 主色竖线,最不会出错的排版',
palette: ['#07c160', '#1677ff', '#fa8c16', '#eb2f96', '#13c2c2', '#722ed1'],
styles: (color) =>
persona(color, {
h2: `font-size: 18px; font-weight: bold; color: #262626; line-height: 1.5; margin: 32px 0 16px; padding-left: 12px; border-left: 4px solid ${color};`,
h3: `font-size: 16px; font-weight: bold; color: #404040; line-height: 1.5; margin: 24px 0 12px; padding-left: 10px; border-left: 3px solid ${rgba(color, 0.45)};`,
blockquote: `margin: 22px 8px; padding: 2px 0 2px 14px; border-left: 2px solid ${rgba(color, 0.6)}; background: none;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: #8a8a8a;`,
img: `max-width: 100%; border-radius: 4px; display: block; margin: 20px auto; border: 1px solid #f0f0f0;`,
hr: `border: none; border-top: 1px solid #ececec; margin: 32px 0;`,
strong: `font-weight: bold; color: ${color};`,
}),
},
{
key: 'minimal-underline',
name: '简·底线',
category: 'minimal',
description: '标题、重点、引用全用「线」表达,一套线条系统',
palette: ['#1677ff', '#07c160', '#f5222d', '#fa8c16', '#2f54eb', '#08979c'],
styles: (color) =>
persona(color, {
h1: `font-size: 22px; font-weight: bold; color: #262626; line-height: 1.4; margin: 28px 0 18px; text-align: center; letter-spacing: 1px;`,
h2: `display: inline-block; font-size: 18px; font-weight: bold; color: #262626; line-height: 1.5; margin: 32px 0 16px; padding-bottom: 6px; border-bottom: 2px solid ${color};`,
h3: `display: inline-block; font-size: 16px; font-weight: bold; color: #404040; line-height: 1.5; margin: 24px 0 12px; padding-bottom: 4px; border-bottom: 1px dashed ${rgba(color, 0.5)};`,
// 引用不用左竖线:上下两条发丝线,延续「线条」语言
blockquote: `margin: 24px 12px; padding: 10px 4px; border-top: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8; background: none;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: #8a8a8a; text-align: center;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 20px auto;`,
hr: `border: none; border-top: 1px solid ${rgba(color, 0.35)}; margin: 32px 0;`,
strong: `font-weight: bold; color: #262626; border-bottom: 2px solid ${rgba(color, 0.55)};`,
a: `color: ${color}; text-decoration: none; border-bottom: 1px solid ${rgba(color, 0.4)};`,
}),
},
{
key: 'minimal-dot',
name: '简·色点',
category: 'minimal',
description: '浅灰纸面 + 白卡引用,像一页干净的笔记应用',
palette: ['#fa541c', '#07c160', '#1677ff', '#eb2f96', '#faad14', '#52c41a'],
decor: {
h2: ['● ', ''],
h3: ['◦ ', ''],
},
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.9; color: #454545; word-break: break-word; text-align: left; padding: 18px 14px; background: #f7f8f9; border-radius: 12px;`,
h2: `font-size: 18px; font-weight: bold; color: #262626; line-height: 1.5; margin: 30px 0 16px;`,
h3: `font-size: 16px; font-weight: bold; color: #404040; line-height: 1.5; margin: 24px 0 12px;`,
blockquote: `margin: 20px 0; padding: 12px 16px; background: #ffffff; border: 1px solid #ececec; border-radius: 10px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.9; color: #8a8a8a;`,
img: `max-width: 100%; border-radius: 10px; display: block; margin: 20px auto; border: 4px solid #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);`,
hr: `border: none; border-top: 1px dashed #dcdcdc; margin: 30px 0;`,
strong: `font-weight: bold; color: ${color};`,
}),
},
{
key: 'minimal-ghost-number',
name: '简·幽灵编号',
category: 'minimal',
description: '超大浅色编号 + 两倍行距留白,编辑部级的呼吸感',
palette: ['#8c8c8c', '#1677ff', '#07c160', '#eb2f96', '#fa8c16', '#722ed1'],
numbering: true,
numberStyle: (color) =>
`font-size: 34px; font-weight: 800; color: ${rgba(color, 0.24)}; margin-right: 10px; font-style: italic; vertical-align: -3px;`,
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 2.05; color: #4a4a4a; word-break: break-word; text-align: left; padding: 4px 0; letter-spacing: 0.4px;`,
p: `margin: 0 0 24px; font-size: 15px; line-height: 2.05; color: #4a4a4a;`,
li: `margin: 8px 0; font-size: 15px; line-height: 2.05; color: #4a4a4a;`,
h2: `font-size: 18px; font-weight: bold; color: #262626; line-height: 1.5; margin: 40px 0 18px;`,
h3: `font-size: 16px; font-weight: bold; color: #404040; line-height: 1.5; margin: 26px 0 14px;`,
// 引用只放大变浅、完全无框,靠字号差表达层级
blockquote: `margin: 28px 20px; padding: 0; border: none; background: none;`,
blockquoteP: `margin: 0; font-size: 16px; line-height: 2; color: #9a9a9a; font-style: italic;`,
img: `max-width: 100%; border-radius: 6px; display: block; margin: 26px auto;`,
hr: `border: none; border-top: 2px solid #262626; width: 28px; margin: 40px 0;`,
strong: `font-weight: bold; color: #262626; background: linear-gradient(to top, ${rgba(color, 0.25)} 38%, transparent 38%);`,
}),
},
{
key: 'minimal-serif-center',
name: '简·衬线居中',
category: 'minimal',
description: '衬线字 + 全部居中 + 细框照片,安静的书卷气',
palette: ['#595959', '#8c6239', '#1677ff', '#c41d7f', '#08979c', '#d4380d'],
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2; color: #404040; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 18px; font-size: 15px; line-height: 2; color: #404040;`,
li: `margin: 6px 0; font-size: 15px; line-height: 2; color: #404040;`,
h1: `font-family: ${FONT_SERIF}; font-size: 22px; font-weight: bold; color: #262626; line-height: 1.5; margin: 30px 0 20px; text-align: center; letter-spacing: 2px;`,
h2: `font-family: ${FONT_SERIF}; font-size: 18px; font-weight: bold; color: ${color}; line-height: 1.6; margin: 34px 0 16px; text-align: center; letter-spacing: 3px;`,
h3: `font-family: ${FONT_SERIF}; font-size: 16px; font-weight: bold; color: #404040; line-height: 1.5; margin: 24px 0 12px; text-align: center; letter-spacing: 2px;`,
blockquote: `margin: 24px 24px; padding: 0; border: none; background: none; text-align: center;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: #8a8a8a; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 22px auto; padding: 5px; border: 1px solid #e8e8e8; background: #ffffff;`,
hr: `border: none; border-top: 1px solid ${rgba(color, 0.35)}; width: 120px; margin: 32px auto;`,
strong: `font-weight: bold; color: ${color};`,
}),
},
];

View File

@@ -0,0 +1,129 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, FONT_MONO, FONT_SERIF, lighten, rgba } from './helpers';
/**
* 复古组5 个):同为「旧」,五套身份各异——
* 铅字=米纸双线铅印;号外=黑框黑白报纸;打字机=红边稿纸等宽字;胶片=整篇暗房棕黑;月份牌=奶油红金画报
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 1.95; color: #4d4438; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 18px; font-size: 15px; line-height: 1.95; color: #4d4438;`,
li: `margin: 6px 0; font-size: 15px; line-height: 1.95; color: #4d4438;`,
...overrides,
});
export const retroThemes: WechatTheme[] = [
{
key: 'retro-press',
name: '复古·铅字',
category: 'retro',
description: '米黄纸面 + 粗细双线 + 首字距标题,老铅印书页',
palette: ['#7a5c3d', '#4a5568', '#8b3a3a', '#3d5a4c', '#6f5b7e', '#a0622d'],
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 1.95; color: #4d4438; word-break: break-word; text-align: left; padding: 22px 16px; background: #f7f1e3;`,
h2: `font-family: ${FONT_SERIF}; font-size: 18px; font-weight: bold; color: #3a332a; line-height: 1.6; margin: 34px 0 16px; text-align: center; letter-spacing: 3px; padding: 10px 0; border-top: 3px double ${rgba(color, 0.6)}; border-bottom: 3px double ${rgba(color, 0.6)};`,
h3: `font-family: ${FONT_SERIF}; font-size: 16px; font-weight: bold; color: ${darken(color, 0.12)}; line-height: 1.6; margin: 24px 0 12px; letter-spacing: 2px;`,
blockquote: `margin: 24px 14px; padding: 0; border: none; background: none; text-align: center;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: #776a55; font-style: italic; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 22px auto; border: 1px solid #d9cdb2; padding: 6px; background: #fffdf7; filter: sepia(18%);`,
hr: `border: none; border-top: 1px solid ${rgba(color, 0.45)}; width: 180px; margin: 32px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.15)};`,
}),
},
{
key: 'retro-extra',
name: '复古·号外',
category: 'retro',
description: '3px 黑框旧报纸:黑白照片、粗黑标题、通栏黑线',
palette: ['#1f1c17', '#8b3a3a', '#4a5568', '#3d5a4c', '#7a5c3d', '#6f5b7e'],
decor: {
h2: ['【', '】'],
},
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 1.9; color: #33302a; word-break: break-word; text-align: left; padding: 20px 16px; background: #f2ede0; border: 3px double #1f1c17;`,
h1: `font-family: ${FONT_SERIF}; font-size: 24px; font-weight: 900; color: #1f1c17; line-height: 1.35; margin: 26px 0 18px; text-align: center; letter-spacing: 2px; border-bottom: 3px solid #1f1c17; padding-bottom: 12px;`,
h2: `font-family: ${FONT_SERIF}; font-size: 19px; font-weight: 900; color: #1f1c17; line-height: 1.5; margin: 32px 0 16px; letter-spacing: 1px;`,
h3: `display: inline-block; font-family: ${FONT_SERIF}; font-size: 15px; font-weight: bold; color: #f2ede0; line-height: 1.5; margin: 24px 0 12px; padding: 2px 12px; background: #1f1c17; letter-spacing: 2px;`,
blockquote: `margin: 22px 0; padding: 12px 16px; background: rgba(31, 28, 23, 0.05); border-top: 1px solid #1f1c17; border-bottom: 1px solid #1f1c17;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 1.9; color: #55503f;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 22px auto; border: 1px solid #1f1c17; filter: grayscale(88%) sepia(12%);`,
hr: `border: none; border-top: 3px solid #1f1c17; margin: 30px 0;`,
strong: `font-weight: 900; color: #1f1c17; border-bottom: 3px double ${rgba(color === '#1f1c17' ? '#8b3a3a' : color, 0.7)};`,
}),
},
{
key: 'retro-typewriter',
name: '复古·打字机',
category: 'retro',
description: '左侧红边线稿纸 + 等宽字,一页敲出来的手稿',
palette: ['#8b3a3a', '#4a5568', '#3d5a4c', '#7a5c3d', '#6f5b7e', '#1f1c17'],
decor: {
h3: ['>> ', ''],
},
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_MONO}; font-size: 14px; line-height: 2; color: #3f3b33; word-break: break-word; text-align: left; padding: 22px 16px 22px 30px; background: #fbfaf4; background-image: linear-gradient(90deg, transparent 20px, ${rgba(color, 0.5)} 20px, ${rgba(color, 0.5)} 22px, transparent 22px); border: 1px solid #e5dfc9;`,
p: `margin: 0 0 18px; font-size: 14px; line-height: 2; color: #3f3b33;`,
li: `margin: 6px 0; font-size: 14px; line-height: 2; color: #3f3b33;`,
h2: `display: inline-block; font-family: ${FONT_MONO}; font-size: 16px; font-weight: bold; color: #2f2b24; line-height: 1.6; margin: 32px 0 16px; padding: 2px 8px; background: ${rgba(color, 0.14)}; letter-spacing: 1px;`,
h3: `font-family: ${FONT_MONO}; font-size: 14px; font-weight: bold; color: ${darken(color, 0.1)}; line-height: 1.6; margin: 24px 0 12px;`,
blockquote: `margin: 22px 0; padding: 0 0 0 14px; border-left: 3px double ${rgba(color, 0.6)}; background: none;`,
blockquoteP: `margin: 0; font-family: ${FONT_MONO}; font-size: 13px; line-height: 2; color: #7d7666;`,
img: `max-width: 100%; border-radius: 0; display: block; margin: 22px auto; border: 1px dashed #c9bfa4; padding: 6px; background: #ffffff; filter: sepia(25%);`,
hr: `border: none; border-top: 1px dashed #c9bfa4; margin: 30px 0;`,
strong: `font-weight: bold; color: #2f2b24; background: ${rgba(color, 0.16)}; padding: 0 4px;`,
code: `font-family: ${FONT_MONO}; font-size: 13px; color: ${darken(color, 0.1)}; background: ${rgba(color, 0.1)}; padding: 2px 6px; border-radius: 2px;`,
}),
},
{
key: 'retro-film',
name: '复古·胶片',
category: 'retro',
description: '整篇暗房棕黑 + 齿孔胶片框,放映机里的旧时光',
palette: ['#d3a95f', '#c98f6b', '#b9a06a', '#a8956e', '#cf9d50', '#c2a878'],
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 2; color: #d6cbb8; word-break: break-word; text-align: left; padding: 24px 16px; background: #211d18; border-radius: 6px; border: 1px solid #3a332a;`,
h1: `font-family: ${FONT_SERIF}; font-size: 21px; font-weight: bold; color: ${lighten(color, 0.2)}; line-height: 1.5; margin: 28px 0 18px; text-align: center; letter-spacing: 3px;`,
p: `margin: 0 0 18px; font-size: 15px; line-height: 2; color: #d6cbb8;`,
li: `margin: 6px 0; font-size: 15px; line-height: 2; color: #d6cbb8;`,
h2: `font-family: ${FONT_SERIF}; font-size: 17px; font-weight: bold; color: ${lighten(color, 0.15)}; line-height: 1.6; margin: 32px 0 16px; text-align: center; letter-spacing: 4px; padding-bottom: 10px; background: linear-gradient(90deg, transparent, ${rgba(color, 0.7)}, transparent) left bottom no-repeat; background-size: 100% 1px;`,
h3: `font-family: ${FONT_SERIF}; font-size: 15px; font-weight: bold; color: ${lighten(color, 0.1)}; line-height: 1.6; margin: 24px 0 12px; letter-spacing: 2px;`,
blockquote: `margin: 24px 10px; padding: 13px 16px; background: rgba(255, 255, 255, 0.045); border-left: 2px solid ${rgba(color, 0.7)}; border-radius: 2px;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: #b3a688;`,
img: `max-width: 100%; border-radius: 2px; display: block; margin: 24px auto; border-top: 10px dotted #0e0c09; border-bottom: 10px dotted #0e0c09; background: #0e0c09; padding: 6px 0; filter: sepia(35%) contrast(92%);`,
hr: `border: none; border-top: 1px dashed ${rgba(color, 0.4)}; margin: 32px 0;`,
strong: `font-weight: bold; color: ${lighten(color, 0.18)};`,
em: `font-style: italic; color: #efe6d2;`,
a: `color: ${lighten(color, 0.12)}; text-decoration: none; border-bottom: 1px solid ${rgba(color, 0.5)};`,
table: `border-collapse: collapse; width: 100%; margin: 0 0 16px; font-size: 14px;`,
th: `border: 1px solid #3a332a; padding: 8px 12px; background: rgba(255, 255, 255, 0.05); color: ${lighten(color, 0.15)}; font-weight: bold; text-align: left;`,
td: `border: 1px solid #3a332a; padding: 8px 12px; color: #d6cbb8;`,
}),
},
{
key: 'retro-poster',
name: '复古·月份牌',
category: 'retro',
description: '奶油纸 + 红金双框 + 圆弧标题牌,旧上海画报',
palette: ['#b23a48', '#8b6f3d', '#3d5a6c', '#7a4a5e', '#a0622d', '#4c6a52'],
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_SERIF}; font-size: 15px; line-height: 1.95; color: #574a3a; word-break: break-word; text-align: left; padding: 26px 18px; background: #fdf6e8; border: 2px solid ${rgba(color, 0.65)}; outline: 1px solid ${rgba('#c9a227', 0.6)}; outline-offset: 5px; border-radius: 2px;`,
h2: `display: inline-block; font-family: ${FONT_SERIF}; font-size: 17px; font-weight: bold; color: #fdf6e8; line-height: 1.6; margin: 32px 0 18px; padding: 6px 22px; background: ${color}; border-radius: 999px 999px 4px 4px; letter-spacing: 4px; box-shadow: 0 3px 0 ${rgba('#c9a227', 0.55)};`,
h3: `font-family: ${FONT_SERIF}; font-size: 15px; font-weight: bold; color: ${darken(color, 0.1)}; line-height: 1.6; margin: 24px 0 12px; letter-spacing: 3px; text-align: center;`,
blockquote: `margin: 24px 12px; padding: 13px 18px; background: rgba(255, 255, 255, 0.7); border: 1px solid ${rgba('#c9a227', 0.55)}; border-radius: 14px; text-align: center;`,
blockquoteP: `margin: 0; font-family: ${FONT_SERIF}; font-size: 14px; line-height: 2; color: #7d6a4d; letter-spacing: 1px;`,
img: `max-width: 100%; border-radius: 50% 50% 4px 4px / 16% 16% 4px 4px; display: block; margin: 24px auto; border: 4px solid #ffffff; outline: 1px solid ${rgba(color, 0.5)}; box-shadow: 0 4px 14px rgba(120, 90, 60, 0.2); filter: sepia(12%);`,
hr: `border: none; height: 6px; background: radial-gradient(circle, ${rgba('#c9a227', 0.7)} 2px, transparent 2px); background-size: 16px 6px; width: 170px; margin: 32px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.1)}; border-bottom: 2px solid ${rgba('#c9a227', 0.7)};`,
}),
},
];

View File

@@ -0,0 +1,137 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, FONT_MONO, gradient, lighten, rgba } from './helpers';
/**
* 科技组5 个):同为「酷」,五套身份各异——
* 渐变=发布会白;终端=整篇黑底命令行;霓虹=白底光晕;方括号=等宽注释稿;芯片=浅蓝图纸
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-size: 15px; line-height: 1.8; color: #334155; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 16px; font-size: 15px; line-height: 1.8; color: #334155;`,
li: `margin: 5px 0; font-size: 15px; line-height: 1.8; color: #334155;`,
code: `font-family: ${FONT_MONO}; font-size: 13px; color: ${darken(color, 0.1)}; background: ${rgba(color, 0.1)}; padding: 2px 6px; border-radius: 4px;`,
pre: `background: #0f172a; border-radius: 10px; padding: 14px 16px; overflow-x: auto; margin: 0 0 16px; border: 1px solid ${rgba(color, 0.35)};`,
preCode: `font-family: ${FONT_MONO}; font-size: 13px; line-height: 1.6; color: #e2e8f0; background: none; padding: 0; display: block; white-space: pre-wrap;`,
...overrides,
});
export const techThemes: WechatTheme[] = [
{
key: 'tech-gradient',
name: '科技·渐变',
category: 'tech',
description: '渐变标题条 + 渐变引用卡,科技发布会的白色主视觉',
palette: ['#6366f1', '#0ea5e9', '#8b5cf6', '#06b6d4', '#3b82f6', '#10b981'],
styles: (color) =>
persona(color, {
h2: `font-size: 17px; font-weight: bold; color: #ffffff; line-height: 1.5; margin: 32px 0 16px; padding: 9px 16px; background: ${gradient(color, 120)}; border-radius: 8px;`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${darken(color, 0.15)}; line-height: 1.5; margin: 24px 0 12px; padding: 3px 12px; background: ${rgba(color, 0.1)}; border-radius: 999px;`,
blockquote: `margin: 20px 0; padding: 14px 16px; background: linear-gradient(120deg, ${rgba(color, 0.1)}, ${rgba(color, 0.02)}); border-left: 4px solid ${color}; border-radius: 10px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.8; color: ${darken(color, 0.28)};`,
img: `max-width: 100%; border-radius: 12px; display: block; margin: 20px auto; box-shadow: 0 8px 24px ${rgba(color, 0.2)};`,
hr: `border: none; height: 2px; background: linear-gradient(90deg, transparent, ${rgba(color, 0.6)}, transparent); margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.12)};`,
}),
},
{
key: 'tech-terminal',
name: '科技·终端',
category: 'tech',
description: '整篇黑底命令行:$ 标题、绿字光标、深色面板引用',
palette: ['#22c55e', '#06b6d4', '#eab308', '#f97316', '#a855f7', '#3b82f6'],
decor: {
h2: ['$ ', ''],
h3: ['> ', ''],
},
styles: (color) =>
persona(color, {
container: `font-size: 14px; line-height: 1.9; color: #c9d1d9; word-break: break-word; text-align: left; padding: 22px 16px; background: #0d1117; border-radius: 10px; border: 1px solid #21262d;`,
h1: `font-family: ${FONT_MONO}; font-size: 20px; font-weight: bold; color: #f0f6fc; line-height: 1.4; margin: 26px 0 18px;`,
p: `margin: 0 0 16px; font-size: 14px; line-height: 1.9; color: #c9d1d9;`,
li: `margin: 5px 0; font-size: 14px; line-height: 1.9; color: #c9d1d9;`,
h2: `font-family: ${FONT_MONO}; font-size: 16px; font-weight: bold; color: ${lighten(color, 0.12)}; line-height: 1.6; margin: 32px 0 16px;`,
h3: `font-family: ${FONT_MONO}; font-size: 14px; font-weight: bold; color: #8b949e; line-height: 1.6; margin: 24px 0 12px;`,
blockquote: `margin: 20px 0; padding: 13px 16px; background: #161b22; border-left: 4px solid ${color}; border-radius: 6px;`,
blockquoteP: `margin: 0; font-size: 13px; line-height: 1.9; color: #8b949e; font-family: ${FONT_MONO};`,
img: `max-width: 100%; border-radius: 8px; display: block; margin: 20px auto; border: 1px solid #30363d;`,
hr: `border: none; border-top: 1px dashed #30363d; margin: 30px 0;`,
strong: `font-weight: bold; color: ${lighten(color, 0.15)}; font-family: ${FONT_MONO};`,
em: `font-style: italic; color: #e6edf3;`,
a: `color: ${lighten(color, 0.1)}; text-decoration: none; border-bottom: 1px dashed ${rgba(color, 0.6)};`,
code: `font-family: ${FONT_MONO}; font-size: 13px; color: ${lighten(color, 0.2)}; background: #161b22; padding: 2px 6px; border-radius: 4px; border: 1px solid #30363d;`,
pre: `background: #161b22; border-radius: 8px; padding: 14px 16px; overflow-x: auto; margin: 0 0 16px; border: 1px solid #30363d;`,
table: `border-collapse: collapse; width: 100%; margin: 0 0 16px; font-size: 13px;`,
th: `border: 1px solid #30363d; padding: 8px 12px; background: #161b22; color: #f0f6fc; font-weight: bold; text-align: left;`,
td: `border: 1px solid #30363d; padding: 8px 12px; color: #c9d1d9;`,
}),
},
{
key: 'tech-neon',
name: '科技·霓虹',
category: 'tech',
description: '白底霓虹光晕:标题发光、分割线发光、重点词发光',
palette: ['#06b6d4', '#a855f7', '#f43f5e', '#22d3ee', '#4ade80', '#818cf8'],
styles: (color) =>
persona(color, {
h2: `display: inline-block; font-size: 18px; font-weight: bold; color: ${darken(color, 0.15)}; line-height: 1.5; margin: 32px 0 16px; padding-bottom: 6px; border-bottom: 2px solid ${color}; text-shadow: 0 0 10px ${rgba(color, 0.55)};`,
h3: `font-size: 16px; font-weight: bold; color: ${darken(color, 0.1)}; line-height: 1.5; margin: 24px 0 12px; text-shadow: 0 0 8px ${rgba(color, 0.45)};`,
blockquote: `margin: 20px 0; padding: 13px 16px; background: ${rgba(color, 0.05)}; border: 1px solid ${rgba(color, 0.4)}; border-radius: 10px; box-shadow: 0 0 12px ${rgba(color, 0.18)}, inset 0 0 12px ${rgba(color, 0.06)};`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.8; color: ${darken(color, 0.25)};`,
img: `max-width: 100%; border-radius: 10px; display: block; margin: 20px auto; border: 1px solid ${rgba(color, 0.5)}; box-shadow: 0 0 16px ${rgba(color, 0.3)};`,
hr: `border: none; height: 2px; background: ${color}; box-shadow: 0 0 8px ${rgba(color, 0.8)}; margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.08)}; text-shadow: 0 0 6px ${rgba(color, 0.4)};`,
}),
},
{
key: 'tech-bracket',
name: '科技·方括号',
category: 'tech',
description: '整篇等宽字代码稿:[ 标题 ]、// 注释式引用',
palette: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'],
decor: {
h2: ['[ ', ' ]'],
h3: ['< ', ' />'],
},
styles: (color) =>
persona(color, {
container: `font-family: ${FONT_MONO}; font-size: 14px; line-height: 1.9; color: #3b4252; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 16px; font-size: 14px; line-height: 1.9; color: #3b4252;`,
li: `margin: 5px 0; font-size: 14px; line-height: 1.9; color: #3b4252;`,
h2: `font-family: ${FONT_MONO}; font-size: 17px; font-weight: bold; color: #1e293b; line-height: 1.5; margin: 32px 0 16px; letter-spacing: 0.5px;`,
h3: `font-family: ${FONT_MONO}; font-size: 15px; font-weight: bold; color: #475569; line-height: 1.5; margin: 24px 0 12px;`,
// 引用做成 // 注释:无底无框,灰斜体等宽
blockquote: `margin: 20px 0; padding: 0 0 0 14px; border-left: 3px solid #cbd5e1; background: none;`,
blockquoteP: `margin: 0; font-family: ${FONT_MONO}; font-size: 13px; line-height: 1.9; color: #94a3b8; font-style: italic;`,
img: `max-width: 100%; border-radius: 6px; display: block; margin: 20px auto; border: 1px dashed ${rgba(color, 0.55)}; padding: 4px;`,
hr: `border: none; border-top: 2px dashed #cbd5e1; margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.1)}; background: ${rgba(color, 0.09)}; padding: 0 4px; border-radius: 3px;`,
}),
},
{
key: 'tech-chip',
name: '科技·芯片',
category: 'tech',
description: '浅蓝图纸底 + 渐变芯片徽章,工程蓝图气质',
palette: ['#0ea5e9', '#6366f1', '#14b8a6', '#f97316', '#a855f7', '#64748b'],
numbering: true,
numberStyle: (color) =>
`display: inline-block; padding: 2px 10px; background: ${gradient(color, 135)}; color: #ffffff; border-radius: 6px; font-size: 13px; font-weight: bold; font-family: ${FONT_MONO}; margin-right: 10px; vertical-align: 2px; box-shadow: 0 2px 8px ${rgba(color, 0.4)};`,
styles: (color) =>
persona(color, {
// 网格纹理压正文影响阅读,去掉后靠浅蓝纸面 + 芯片编号徽章保留蓝图气质
container: `font-size: 15px; line-height: 1.8; color: #33415e; word-break: break-word; text-align: left; padding: 20px 14px; background: #f4f8fc; border-radius: 8px; border: 1px solid ${rgba(color, 0.25)};`,
h2: `font-size: 18px; font-weight: bold; color: #1e293b; line-height: 1.6; margin: 30px 0 16px;`,
h3: `font-size: 16px; font-weight: bold; color: #334155; line-height: 1.5; margin: 24px 0 12px; padding-left: 10px; border-left: 3px solid ${color};`,
blockquote: `margin: 20px 0; padding: 13px 16px; background: #ffffff; border: 1px dashed ${rgba(color, 0.5)}; border-radius: 8px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.8; color: #64748b;`,
img: `max-width: 100%; border-radius: 6px; display: block; margin: 20px auto; border: 1px solid ${rgba(color, 0.35)}; background: #ffffff; padding: 5px;`,
hr: `border: none; border-top: 1px dashed ${rgba(color, 0.45)}; margin: 30px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.15)};`,
}),
},
];

View File

@@ -0,0 +1,115 @@
/**
* 自定义模板制作指南(单一数据源)
*
* 两处共用,改这里即可同步:
* 1. 「导出模板包」zip 里的 `模板制作说明.md`
* 2. 主题面板「我的」→「怎么制作」教程弹窗markdown-it 渲染同一份文本)
*
* STARTER_TEMPLATE 是「模板的模板」:覆盖全部 21 个元素键的空白样板,
* 用户改改 CSS 就能导入使用,避免从零琢磨结构。
*/
/** 空白样板模板:结构完整、样式中性,是用户自定义的推荐起点 */
export const STARTER_TEMPLATE = {
name: '我的第一个模板',
palette: ['#07c160', '#576b95', '#fa5151'],
styles: {
container:
'font-size: 15px; line-height: 1.9; color: #333333; word-break: break-word; text-align: left; padding: 20px 14px; background: #ffffff;',
h1: 'font-size: 20px; font-weight: bold; color: #1a1a1a; line-height: 1.4; margin: 28px 0 16px; text-align: center;',
h2: 'font-size: 17px; font-weight: bold; color: {{color}}; line-height: 1.5; margin: 24px 0 12px; padding-left: 10px; border-left: 4px solid {{color}};',
h3: 'font-size: 15px; font-weight: bold; color: #333333; line-height: 1.5; margin: 20px 0 10px;',
p: 'margin: 0 0 14px; font-size: 15px; line-height: 1.9; color: #333333;',
blockquote:
'margin: 16px 0; padding: 10px 14px; border-left: 3px solid {{color}}; background: rgba(0, 0, 0, 0.04); border-radius: 0 6px 6px 0;',
blockquoteP: 'margin: 0; font-size: 14px; line-height: 1.8; color: #666666;',
ul: 'margin: 0 0 14px; padding-left: 22px;',
ol: 'margin: 0 0 14px; padding-left: 22px;',
li: 'font-size: 15px; line-height: 1.9; color: #333333; margin: 0 0 6px;',
strong: 'font-weight: bold; color: {{color}};',
em: 'font-style: italic; color: #666666;',
a: 'color: {{color}}; text-decoration: none; border-bottom: 1px solid {{color}};',
hr: 'border: none; border-top: 1px solid #e5e5e5; margin: 24px 0;',
img: 'max-width: 100%; border-radius: 8px; display: block; margin: 16px auto;',
code: 'font-size: 13px; color: {{color}}; background: rgba(0, 0, 0, 0.05); padding: 2px 5px; border-radius: 4px;',
pre: 'background: #2d2d2d; border-radius: 8px; padding: 12px 14px; overflow-x: auto; margin: 0 0 14px;',
preCode:
"color: #e8e8e8; font-size: 13px; line-height: 1.7; font-family: Consolas, 'Courier New', monospace;",
table:
'border-collapse: collapse; width: 100%; margin: 0 0 14px; font-size: 14px;',
th: 'border: 1px solid #e5e5e5; padding: 8px 10px; font-weight: bold; background: rgba(0, 0, 0, 0.03); text-align: left;',
td: 'border: 1px solid #e5e5e5; padding: 8px 10px;',
},
};
/** 制作教程 Markdownzip 内 md 文件与教程弹窗共用) */
export const TEMPLATE_GUIDE_MD = `# 自定义排版模板制作指南
模板是一份 **JSON 文件**,描述「文章里的每类元素 → 一段内联 CSS」。
导入后出现在主题面板的「我的」分类里,用法与内置主题完全一致(可换主色、实时预览、直接发布)。
## 三步上手
1. **拿起点文件**:用压缩包里的 \`模板样板-starter.json\`(结构完整的空白样板);想在某套内置主题上改,就先选中它再点「导出模板包」,包里会有它的完整样式
2. **改样式**:用任意文本编辑器修改 \`styles\` 里的 CSS 值,保存
3. **导入**:编辑器 → 主题面板 → 「我的」→「导入模板」,粘贴或上传这份 JSON
## JSON 结构
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| \`name\` | 是 | 模板名称(最长 50 字,也可在导入弹窗里另填) |
| \`palette\` | 否 | 预设色板HEX 数组(如 \`["#07c160", "#576b95"]\`),第一个是默认主色,最多 8 个 |
| \`styles\` | 是 | 元素样式映射,见下表;至少写一个元素 |
## styles 支持的元素键21 个)
| 键 | 对应内容 |
| --- | --- |
| \`container\` | 整篇文章最外层容器(字号/行高/正文色/背景/内边距) |
| \`h1\` / \`h2\` / \`h3\` | 一二三级标题 |
| \`p\` | 正文段落 |
| \`blockquote\` / \`blockquoteP\` | 引用块容器 / 引用内文字 |
| \`ul\` / \`ol\` / \`li\` | 无序列表 / 有序列表 / 列表项 |
| \`strong\` / \`em\` | 加粗重点词 / 斜体 |
| \`a\` | 链接 |
| \`hr\` | 分割线 |
| \`img\` | 图片 |
| \`code\` / \`pre\` / \`preCode\` | 行内代码 / 代码块容器 / 代码块内文字 |
| \`table\` / \`th\` / \`td\` | 表格 / 表头格 / 单元格 |
没写的键会自动用系统默认样式补齐,所以**只写你关心的元素也能正常用**。
## 主色占位符 {{color}}
样式值里写 \`{{color}}\` 的地方,会在切换主题色时被实时替换成当前主色:
\`\`\`json
{
"h2": "color: {{color}}; border-left: 4px solid {{color}};"
}
\`\`\`
不写占位符的颜色是固定色,不跟随换色。
## 安全清洗规则(这些写了也会被删)
导入时后端会做白名单清洗,以下内容会被自动去除,请不要依赖:
- 双引号 \`"\`、尖括号 \`<\` \`>\`、反斜杠 \`\\\`(防注入;字体名等需要引号时请用**单引号**
- \`javascript:\`\`expression(\` 等危险片段
- 超出 2000 字符的单个样式值会被截断;\`styles\` 以外的未知键会被丢弃
## 公众号兼容建议
- 只用**内联 CSS 能表达**的属性:颜色、字号、行高、内外边距、边框、圆角、背景色/渐变最稳
- 避免 \`position: fixed/absolute\`、动画、\`float\` 等,公众号编辑器可能剥离
- 正文底纹会影响阅读,深色背景请配足够对比度的文字颜色
- 改完先在编辑器里多设备预览(亮色/暗色都过一遍)再发布
## 常见问题
- **导入后样式没生效**:检查键名拼写(区分大小写,如 \`blockquoteP\`);确认值里没有被清洗的字符
- **换主题色没反应**:样式值里没写 \`{{color}}\` 占位符
- **模板列表里找不到**:导入成功会自动切到「我的」并选中;最多保存 100 个模板
`;

View File

@@ -0,0 +1,86 @@
/**
* 排版主题类型定义
* 公众号编辑器只认内联样式style=""),所以主题的产物是「元素 → 内联 CSS 字符串」映射,
* 由 markdown-it 自定义渲染规则在渲染时注入到每个标签上
*/
/** 元素 → 内联样式映射(值为 style 属性字符串,不带引号) */
export interface ThemeStyleSheet {
/** 最外层容器:字体/字号/行高/正文色 */
container: string;
h1: string;
h2: string;
h3: string;
p: string;
blockquote: string;
/** blockquote 内部的 p引用文字本身的颜色 */
blockquoteP: string;
ul: string;
ol: string;
li: string;
strong: string;
em: string;
a: string;
hr: string;
img: string;
/** 行内代码 */
code: string;
/** 代码块容器 */
pre: string;
/** 代码块内 code 标签 */
preCode: string;
table: string;
th: string;
td: string;
}
/** 标题装饰渲染时注入到标题文字前后的字符emoji/引号等),非 CSS */
export interface ThemeDecor {
h1?: [string, string];
h2?: [string, string];
h3?: [string, string];
}
/** 主题分类 keycustom 为「我的模板」伪分类:用户导入的自定义主题,不在内置分类列表里) */
export type ThemeCategory =
| 'business'
| 'chinese'
| 'custom'
| 'cute'
| 'dark'
| 'festival'
| 'gradient'
| 'literary'
| 'magazine'
| 'minimal'
| 'retro'
| 'tech'
| 'vivid';
export interface WechatTheme {
/** 全局唯一 key入库存 nl_wechat_article.theme_key */
key: string;
name: string;
category: ThemeCategory;
/** 一句话风格说明,主题面板展示 */
description: string;
/** 预设色板(第一个为默认主色),换色时快速点选 */
palette: string[];
/**
* 是否给 h2 自动编号01/02…徽章形态的主题用
* 渲染器按出现顺序注入 <span style="numberStyle">01</span>
*/
numbering?: boolean;
/** h2 编号徽章的内联样式numbering=true 时必填) */
numberStyle?: (color: string) => string;
/** 标题文字前后的装饰字符 */
decor?: ThemeDecor;
/** 根据主色生成整套内联样式 */
styles: (color: string) => ThemeStyleSheet;
}
/** 分类元信息(主题面板分组用) */
export interface ThemeCategoryMeta {
key: ThemeCategory;
name: string;
}

View File

@@ -0,0 +1,119 @@
import type { ThemeStyleSheet, WechatTheme } from './types';
import { baseStyles, darken, gradient, readableOn, rgba, softShadow } from './helpers';
/**
* 活泼组5 个):同为「甜」,五套身份各异——
* 糖果=白底渐变药丸;表情=彩顶白板;贴纸=描边硬阴影手帐;点线=波点纸面;气泡=灰底聊天界面
*/
const persona = (
color: string,
overrides: Partial<ThemeStyleSheet> = {},
): ThemeStyleSheet =>
baseStyles(color, {
container: `font-size: 15px; line-height: 1.85; color: #4b5563; word-break: break-word; text-align: left; padding: 4px 0;`,
p: `margin: 0 0 17px; font-size: 15px; line-height: 1.85; color: #4b5563;`,
li: `margin: 6px 0; font-size: 15px; line-height: 1.85; color: #4b5563;`,
a: `color: ${darken(color, 0.1)}; text-decoration: none; border-bottom: 2px solid ${rgba(color, 0.5)};`,
...overrides,
});
export const vividThemes: WechatTheme[] = [
{
key: 'vivid-candy',
name: '活泼·糖果',
category: 'vivid',
description: '渐变药丸标题 + 虚线糖纸引用,甜度拉满',
palette: ['#f472b6', '#fb923c', '#a78bfa', '#34d399', '#38bdf8', '#facc15'],
styles: (color) =>
persona(color, {
h2: `display: inline-block; font-size: 17px; font-weight: bold; color: ${readableOn(color)}; line-height: 1.5; margin: 32px 0 16px; padding: 7px 18px; background: ${gradient(color, 120)}; border-radius: 999px; ${softShadow(color, 0.35)}`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${darken(color, 0.2)}; line-height: 1.5; margin: 24px 0 12px; padding: 3px 12px; background: ${rgba(color, 0.14)}; border-radius: 999px;`,
blockquote: `margin: 20px 0; padding: 14px 16px; border: 2px dashed ${rgba(color, 0.5)}; background: ${rgba(color, 0.06)}; border-radius: 14px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: ${darken(color, 0.25)};`,
img: `max-width: 100%; border-radius: 16px; display: block; margin: 20px auto; ${softShadow(color, 0.28)}`,
hr: `border: none; border-top: 3px dotted ${rgba(color, 0.45)}; margin: 30px 0;`,
strong: `font-weight: bold; color: #374151; background: linear-gradient(to top, ${rgba(color, 0.35)} 40%, transparent 40%); padding: 0 2px;`,
}),
},
{
key: 'vivid-emoji',
name: '活泼·表情',
category: 'vivid',
description: '白板卡片 + 表情标题 + 圆气泡引用,元气满格',
palette: ['#fb923c', '#f472b6', '#34d399', '#38bdf8', '#a78bfa', '#f87171'],
decor: {
h2: ['✨ ', ' ✨'],
h3: ['🌈 ', ''],
},
styles: (color) =>
persona(color, {
// 不用顶部色条压场:白板卡片全圆角 + 柔和投影,个性靠表情标题和气泡引用
container: `font-size: 15px; line-height: 1.85; color: #4b5563; word-break: break-word; text-align: left; padding: 18px 14px; background: #fffefb; border-radius: 14px; box-shadow: 0 2px 12px ${rgba(color, 0.14)};`,
h2: `font-size: 18px; font-weight: bold; color: #374151; line-height: 1.6; margin: 30px 0 16px; text-align: center;`,
h3: `font-size: 16px; font-weight: bold; color: ${darken(color, 0.18)}; line-height: 1.5; margin: 24px 0 12px;`,
blockquote: `margin: 20px 12px; padding: 13px 18px; background: ${rgba(color, 0.1)}; border: none; border-radius: 22px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: ${darken(color, 0.28)}; text-align: center;`,
img: `max-width: 100%; border-radius: 18px; display: block; margin: 20px auto;`,
hr: `border: none; border-top: 3px dotted ${rgba(color, 0.5)}; width: 160px; margin: 30px auto;`,
strong: `font-weight: bold; color: ${darken(color, 0.15)};`,
}),
},
{
key: 'vivid-sticker',
name: '活泼·贴纸',
category: 'vivid',
description: '全套描边 + 错位硬阴影:标题、引用、图片都是贴纸',
palette: ['#a78bfa', '#f472b6', '#34d399', '#fb923c', '#38bdf8', '#fbbf24'],
styles: (color) =>
persona(color, {
h2: `display: inline-block; font-size: 17px; font-weight: bold; color: ${darken(color, 0.25)}; line-height: 1.5; margin: 32px 0 18px; padding: 6px 16px; background: #ffffff; border: 2px solid ${color}; border-radius: 12px; box-shadow: 4px 4px 0 ${rgba(color, 0.35)};`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${darken(color, 0.25)}; line-height: 1.5; margin: 24px 0 12px; padding: 2px 10px; border: 2px dashed ${rgba(color, 0.55)}; border-radius: 10px; transform: rotate(-1deg);`,
blockquote: `margin: 22px 4px 22px 0; padding: 13px 16px; background: #ffffff; border: 2px solid ${rgba(color, 0.7)}; border-radius: 12px; box-shadow: 5px 5px 0 ${rgba(color, 0.25)};`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: ${darken(color, 0.28)};`,
img: `max-width: 100%; border-radius: 14px; display: block; margin: 22px auto; border: 3px solid #ffffff; outline: 2px solid ${color}; box-shadow: 5px 5px 0 ${rgba(color, 0.3)};`,
hr: `border: none; height: 6px; background: repeating-linear-gradient(90deg, ${rgba(color, 0.5)} 0 14px, transparent 14px 24px); border-radius: 3px; margin: 30px 0;`,
strong: `font-weight: bold; color: #ffffff; background: ${color}; padding: 0 6px; border-radius: 6px;`,
}),
},
{
key: 'vivid-dotline',
name: '活泼·点线',
category: 'vivid',
description: '圆点描边 + 点状底线,轻快跳跃的节奏',
palette: ['#34d399', '#38bdf8', '#f472b6', '#fb923c', '#a78bfa', '#4ade80'],
decor: {
h2: ['●●● ', ''],
},
styles: (color) =>
persona(color, {
// 圆点纹理不铺正文底(影响阅读),点的语言全部放在边框/底线/分割线上
container: `font-size: 15px; line-height: 1.85; color: #4b5563; word-break: break-word; text-align: left; padding: 20px 14px; background: #ffffff; border: 2px dotted ${rgba(color, 0.45)}; border-radius: 12px;`,
h2: `font-size: 17px; font-weight: bold; color: #374151; line-height: 1.6; margin: 30px 0 16px; padding-bottom: 8px; border-bottom: 3px dotted ${rgba(color, 0.6)}; letter-spacing: 0.5px;`,
h3: `font-size: 15px; font-weight: bold; color: ${darken(color, 0.2)}; line-height: 1.5; margin: 24px 0 12px;`,
blockquote: `margin: 20px 0; padding: 13px 16px; background: #ffffff; border: 2px dotted ${rgba(color, 0.55)}; border-radius: 12px;`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: #6b7280;`,
img: `max-width: 100%; border-radius: 12px; display: block; margin: 20px auto; border: 4px solid #ffffff; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);`,
hr: `border: none; border-top: 3px dotted ${rgba(color, 0.5)}; margin: 28px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.18)}; border-bottom: 3px dotted ${rgba(color, 0.6)};`,
}),
},
{
key: 'vivid-bubble',
name: '活泼·气泡',
category: 'vivid',
description: '灰底聊天界面:标题是对方气泡,引用是白色回复',
palette: ['#38bdf8', '#f472b6', '#34d399', '#a78bfa', '#fb923c', '#22d3ee'],
styles: (color) =>
persona(color, {
container: `font-size: 15px; line-height: 1.85; color: #45505c; word-break: break-word; text-align: left; padding: 18px 14px; background: #f2f4f7; border-radius: 12px;`,
h2: `display: inline-block; font-size: 16px; font-weight: bold; color: ${readableOn(color)}; line-height: 1.5; margin: 30px 0 16px; padding: 8px 18px; background: ${color}; border-radius: 18px 18px 18px 4px; ${softShadow(color, 0.3)}`,
h3: `display: inline-block; font-size: 15px; font-weight: bold; color: ${darken(color, 0.22)}; line-height: 1.5; margin: 24px 0 12px; padding: 4px 14px; background: ${rgba(color, 0.15)}; border-radius: 4px 14px 14px 14px;`,
blockquote: `margin: 20px 0 20px 24px; padding: 13px 16px; background: #ffffff; border: none; border-radius: 18px 4px 18px 18px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);`,
blockquoteP: `margin: 0; font-size: 14px; line-height: 1.85; color: #6b7280;`,
img: `max-width: 100%; border-radius: 14px; display: block; margin: 20px auto; border: 4px solid #ffffff; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1);`,
hr: `border: none; border-top: 1px solid #dde2e9; margin: 28px 0;`,
strong: `font-weight: bold; color: ${darken(color, 0.15)};`,
}),
},
];

View File

@@ -0,0 +1,515 @@
<script lang="ts" setup>
// 图文创作列表液态毛玻璃封面卡片画廊Apple Liquid Glass 风格)
// 每张卡片是「封面 + 状态角标 + 标题摘要 + 元信息」,底部行内操作 编辑/发布/版本/更多
// 点卡片进全屏编辑器;发布 / 版本 / 删除 用事件冒泡阻止避免误触进入编辑器
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import {
Button,
Dropdown,
Empty,
Input,
Menu,
message,
Modal,
Pagination,
Select,
Spin,
Tag,
} from 'ant-design-vue';
import { GlassCard, GlassScene } from '#/components/glass';
import { Icon } from '#/components/icon';
import { getWechatAccountOption } from '../wechat-account/api';
import {
deleteWechatArticle,
getWechatArticleList,
getWechatArticlePublishStatus,
} from './api';
import PublishModal from './components/publish-modal.vue';
import VersionDrawer from './components/version-drawer.vue';
import {
ARTICLE_STATUS_COLOR,
ARTICLE_STATUS_OPTIONS,
} from './config/constants';
defineOptions({
name: 'MediaWechatArticle',
});
const router = useRouter();
const [PublishModalComp, publishModalApi] = useVbenModal({
connectedComponent: PublishModal,
});
const [VersionDrawerComp, versionDrawerApi] = useVbenDrawer({
connectedComponent: VersionDrawer,
});
const loading = ref(false);
const list = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(12);
/** 筛选条件(对齐后端 queryFieldtitle 模糊status/account_id 精确) */
const filters = reactive({
title: '',
status: undefined as number | undefined,
account_id: undefined as number | undefined,
});
/** 账号下拉选项(筛选用) */
const accountOptions = ref<{ label: string; value: number }[]>([]);
/**
* 拉取图文列表(卡片分页数据源)
*/
const fetchList = async () => {
loading.value = true;
try {
const res = await getWechatArticleList({
page: page.value,
pageSize: pageSize.value,
title: filters.title || undefined,
status: filters.status,
account_id: filters.account_id,
});
list.value = res?.items ?? [];
total.value = Number(res?.total ?? 0);
} finally {
loading.value = false;
}
};
/** 查询(回到第一页) */
const handleSearch = () => {
page.value = 1;
fetchList();
};
/** 重置筛选 */
const handleReset = () => {
filters.title = '';
filters.status = undefined;
filters.account_id = undefined;
handleSearch();
};
/** 分页切换 */
const handlePageChange = (p: number, size: number) => {
page.value = p;
pageSize.value = size;
fetchList();
};
/**
* 进入全屏编辑器:带 id 是编辑,不带 id 是新建
*/
const goEditor = (row?: any) => {
router.push({
path: '/media/wechat-article/editor',
query: row?.id ? { id: row.id } : {},
});
};
/**
* 删除图文:后端会写「删除」版本流水,可从版本记录追溯
*/
const handleDelete = (row: any) => {
Modal.confirm({
title: '删除确认',
content: `确定要删除「${row.title}」吗?删除会记录版本流水。`,
okType: 'danger',
onOk: async () => {
await deleteWechatArticle({ ids: [row.id] });
message.success('删除成功');
fetchList();
},
});
};
/**
* 打开发布弹窗;发布完成后刷新列表
*/
const openPublish = (row: any) => {
publishModalApi
.setData({
articleId: row.id,
onPublished: fetchList,
})
.open();
};
/**
* 打开版本抽屉:查看历史版本、对比与回退,回退后刷新列表
*/
const openVersions = (row: any) => {
versionDrawerApi
.setData({
articleId: row.id,
articleTitle: row.title,
onRollback: fetchList,
})
.open();
};
/**
* 发布中状态手动刷新:向微信查询发布任务结果并回写
*/
const refreshPublishStatus = async (row: any) => {
loading.value = true;
try {
const res = await getWechatArticlePublishStatus(row.id);
message.success(`当前状态:${res.status_text}`);
fetchList();
} finally {
loading.value = false;
}
};
/** 查看已发布文章 */
const openArticleUrl = (row: any) => {
if (row.article_url) {
window.open(row.article_url, '_blank');
}
};
/** 「更多」菜单分发 */
const handleMore = (key: number | string, row: any) => {
switch (String(key)) {
case 'delete': {
handleDelete(row);
break;
}
case 'refresh': {
refreshPublishStatus(row);
break;
}
case 'view': {
openArticleUrl(row);
break;
}
default: {
break;
}
}
};
/** 无封面时的渐变底(用文章主题色调出品牌感,而不是灰底占位) */
const coverFallbackStyle = (row: any) => {
const color = row.theme_color || '#07c160';
return {
background: `linear-gradient(135deg, ${color}, color-mix(in srgb, ${color} 55%, #1a1a2e))`,
};
};
onMounted(() => {
fetchList();
// 账号选项用于筛选(默认账号标出来)
getWechatAccountOption().then((accounts: any[]) => {
accountOptions.value = (accounts ?? []).map((item: any) => ({
label: item.is_default === 1 ? `${item.name}(默认)` : item.name,
value: item.id,
}));
});
});
</script>
<template>
<Page auto-content-height>
<PublishModalComp />
<VersionDrawerComp />
<GlassScene>
<!-- 顶部工具条标题 + 筛选 + 新建 -->
<GlassCard class="toolbar">
<div class="toolbar-title">
<div class="toolbar-name">图文创作</div>
<div class="toolbar-desc">60 套排版主题 · 多设备亮暗预览 · 一键发布到公众号</div>
</div>
<div class="toolbar-filters">
<Input
v-model:value="filters.title"
allow-clear
class="filter-item"
placeholder="标题关键词"
@press-enter="handleSearch"
/>
<Select
v-model:value="filters.status"
allow-clear
class="filter-item filter-status"
:options="ARTICLE_STATUS_OPTIONS"
placeholder="状态"
/>
<Select
v-model:value="filters.account_id"
allow-clear
class="filter-item"
:options="accountOptions"
placeholder="发布账号"
show-search
:filter-option="
(input: string, option: any) =>
option?.label?.toLowerCase().includes(input.toLowerCase())
"
/>
<Button @click="handleSearch">
<Icon class="mr-1" icon="ant-design:search-outlined" />
查询
</Button>
<Button @click="handleReset">重置</Button>
</div>
<Button type="primary" @click="goEditor()">
<Icon class="mr-1" icon="ant-design:plus-outlined" />
新建图文
</Button>
</GlassCard>
<Spin :spinning="loading">
<!-- 封面卡片画廊 -->
<div v-if="list.length > 0" class="card-grid">
<GlassCard
v-for="row in list"
:key="row.id"
class="article-card"
pressable
@click="goEditor(row)"
>
<!-- 封面区2.35:1 公众号头图比例;无封面用主题色渐变兜底 -->
<div class="cover">
<img
v-if="row.cover_url"
alt=""
class="cover-img"
:src="row.cover_url"
/>
<div v-else class="cover-fallback" :style="coverFallbackStyle(row)">
<span class="cover-glyph">图文</span>
</div>
<Tag
class="cover-status"
:color="ARTICLE_STATUS_COLOR[row.status] ?? 'default'"
:title="row.status === 3 ? row.publish_error : ''"
>
{{ row.status_text }}
</Tag>
</div>
<div class="article-body">
<div class="article-title" :title="row.title">{{ row.title }}</div>
<div class="article-digest">
{{ row.digest || '暂无摘要,编辑时可补充' }}
</div>
<div class="article-meta">
<span v-if="row.account_name" class="meta-chip">
<Icon icon="mdi:wechat" />
{{ row.account_name }}
</span>
<span class="meta-chip">v{{ row.version_no }}</span>
<span class="meta-time">{{ row.updated_at || row.created_at }}</span>
</div>
</div>
<!-- 行内操作:阻止冒泡避免误触进编辑器 -->
<div class="article-foot" @click.stop>
<Button size="small" type="text" @click="goEditor(row)">
<Icon class="mr-1" icon="uil:edit" />
编辑
</Button>
<Button size="small" type="text" @click="openPublish(row)">
<Icon class="mr-1" icon="ant-design:send-outlined" />
发布
</Button>
<Button size="small" type="text" @click="openVersions(row)">
<Icon class="mr-1" icon="ant-design:history-outlined" />
版本
</Button>
<Dropdown>
<Button size="small" type="text">
<Icon icon="ant-design:more-outlined" />
</Button>
<template #overlay>
<Menu @click="({ key }: any) => handleMore(key, row)">
<Menu.Item v-if="row.status === 1" key="refresh">
刷新发布状态
</Menu.Item>
<Menu.Item v-if="row.article_url" key="view">
查看已发布文章
</Menu.Item>
<Menu.Item key="delete" danger>删除</Menu.Item>
</Menu>
</template>
</Dropdown>
</div>
</GlassCard>
</div>
<!-- 空态:引导新建 -->
<GlassCard v-else-if="!loading" class="empty-card">
<Empty description="还没有图文从一篇排版精美的文章开始">
<Button type="primary" @click="goEditor()">
<Icon class="mr-1" icon="ant-design:plus-outlined" />
新建图文
</Button>
</Empty>
</GlassCard>
</Spin>
<!-- 分页 -->
<div v-if="total > 0" class="pager">
<Pagination
:current="page"
:page-size="pageSize"
:page-size-options="['12', '24', '48']"
show-size-changer
:show-total="(t: number) => `${t} 篇图文`"
:total="total"
@change="handlePageChange"
/>
</div>
</GlassScene>
</Page>
</template>
<style scoped>
/* ===== 顶部工具条 ===== */
.toolbar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 14px 18px;
margin-bottom: 16px;
}
.toolbar-title {
min-width: 200px;
}
.toolbar-name {
font-size: 16px;
font-weight: 600;
color: hsl(var(--foreground));
}
.toolbar-desc {
margin-top: 2px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.toolbar-filters {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
justify-content: flex-end;
}
.filter-item {
width: 160px;
}
.filter-status {
width: 110px;
}
/* ===== 卡片网格 ===== */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.article-card {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 封面按公众号头图 2.35:1 比例 */
.cover {
position: relative;
aspect-ratio: 2.35 / 1;
overflow: hidden;
border-radius: 17px 17px 0 0;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.cover-glyph {
font-size: 34px;
font-weight: 700;
color: rgb(255 255 255 / 85%);
text-shadow: 0 2px 12px rgb(0 0 0 / 25%);
}
.cover-status {
position: absolute;
top: 10px;
right: 6px;
}
/* ===== 内容区 ===== */
.article-body {
flex: 1;
padding: 12px 16px 4px;
}
.article-title {
font-size: 15px;
font-weight: 600;
line-height: 1.45;
color: hsl(var(--foreground));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 43px;
}
.article-digest {
margin-top: 4px;
font-size: 12px;
color: hsl(var(--muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
flex-wrap: wrap;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 8px;
border-radius: 999px;
background: hsl(var(--muted) / 0.5);
}
.meta-time {
margin-left: auto;
white-space: nowrap;
}
/* ===== 底部操作 ===== */
.article-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
padding: 8px 12px 10px;
margin-top: 8px;
border-top: 1px solid hsl(var(--border) / 0.5);
}
.empty-card {
padding: 48px 0;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@@ -8,13 +8,6 @@ const prefix = 'admin/';
export async function getAdminList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 分页查询用户列表
* @param data
*/
export async function getAdminAccountBalance(data: any = {}) {
return requestClient.get<any>(`${prefix}my-balance`, { params: data });
}
/**
* 获取管理员详情
@@ -42,48 +35,23 @@ export async function updateAdmin(data: Record<string, any>) {
/**
* 删除管理员
* @param data
* @param data 形如 { ids: number[] }
*/
export async function deleteAdmin(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
/**
* 修改密码
* @param data
* 重置指定管理员的密码
* 后端 admin/reset-password 需要 id + password + new_password两次一致不校验旧密码
* 故这里把新密码同时填入 password 与 new_password
* @param id 目标管理员 ID
* @param password 要设置的新密码
*/
export async function updatePassword(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update-password`, data);
}
/**
* 删除管理员
* @param id
*/
export async function resetPassword(id: number) {
export async function resetPassword(id: number, password: string) {
return requestClient.post<any>(`${prefix}reset-password`, {
id,
password,
new_password: password,
});
}
/**
* 获取我绑定的银行卡
*/
export async function getMyCard() {
return requestClient.get<any>(`${prefix}my-card`);
}
/**
* 获取我绑定的银行卡
*/
export async function getQuickMenuApi() {
return requestClient.get<any>(`${prefix}get-quick-menu`);
}
/**
* 编辑账户绑定银行卡
* @param data
*/
export async function saveCard(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}save-card`, data);
}

View File

@@ -8,9 +8,13 @@ import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Image, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { Icon } from '#/components/icon';
import { TableAction } from '#/components/table-action';
import { deleteAdmin, resetPassword } from './api';
// 重置密码使用的默认新密码:后端不校验旧密码,重置后由管理员告知使用者
const DEFAULT_RESET_PASSWORD = 'Aa123456';
import FormModalDemo from './components/modal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
@@ -62,8 +66,8 @@ const deleteApi = (row: any) => {
};
const resetPasswordApi = (id: number) => {
resetPassword(id).then(() => {
message.success('删除成功!');
resetPassword(id, DEFAULT_RESET_PASSWORD).then(() => {
message.success(`密码已重置为:${DEFAULT_RESET_PASSWORD}`);
gridApi.reload();
});
};

View File

@@ -8,6 +8,7 @@ import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Image, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { Icon } from '#/components/icon';
import { TableAction } from '#/components/table-action';
import AuthMenu from '#/views/system/role/components/auth-menu.vue';

View File

@@ -28,6 +28,8 @@
"build": "pnpm run build --filter=@vben/web-antd",
"bootstrap": "pnpm install",
"build:analyze": "turbo build:analyze",
"build:desktop": "turbo build --filter=@vben/desktop",
"build:desktop:dev": "turbo run build:dev --filter=@vben/desktop",
"build:docker": "./scripts/deploy/build-local-docker-image.sh",
"build:docs": "pnpm run build --filter=@vben/docs",
"build:ele": "pnpm run build --filter=@vben/web-ele",
@@ -45,6 +47,7 @@
"dev:antd": "pnpm -F @vben/web-antd run dev",
"dev:antdv-next": "pnpm -F @vben/web-antdv-next run dev",
"dev": "pnpm -F @vben/web-antd run dev",
"dev:desktop": "turbo run dev --filter=@vben/web-antd --filter=@vben/desktop",
"dev:docs": "pnpm -F @vben/docs run dev",
"dev:ele": "pnpm -F @vben/web-ele run dev",
"dev:naive": "pnpm -F @vben/web-naive run dev",

1567
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -119,6 +119,12 @@ catalog:
defu: ^6.1.7
dotenv: ^17.4.2
echarts: ^6.1.0
electron: ^43.4.0
electron-builder: ^26.15.3
electron-log: ^5.4.4
electron-store: ^11.0.2
electron-updater: ^6.8.9
electron-vite: ^5.0.0
element-plus: ^2.14.3
es-toolkit: ^1.49.0
eslint: ^10.7.0
@@ -215,6 +221,10 @@ catalog:
allowBuilds:
'@parcel/watcher': true
core-js: true
# electron 安装时 postinstall 需要下载平台二进制,必须放行构建脚本
electron: true
# electron-builder 的传递依赖postinstall 解压 Squirrel.Windows 工具
electron-winstaller: true
esbuild: true
lefthook: true
vue-demi: true

View File

@@ -34,6 +34,16 @@
"dependsOn": ["^build"],
"outputs": [".nitro/**", ".output/**"]
},
"@vben/desktop#build": {
"dependsOn": ["^build"],
"outputs": ["out/**", "release/**"],
"cache": false
},
"@vben/desktop#build:dev": {
"dependsOn": ["^build"],
"outputs": ["out/**", "release/**"],
"cache": false
},
"test:e2e": {},
"dev": {
"dependsOn": [],