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
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,3 +61,4 @@ skills-lock.json
|
||||
.atomcode
|
||||
datalog
|
||||
.playwright-mcp
|
||||
/apps/desktop/release/win-unpacked/
|
||||
|
||||
6
.npmrc
6
.npmrc
@@ -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/
|
||||
|
||||
6
apps/desktop/.env.development
Normal file
6
apps/desktop/.env.development
Normal 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
|
||||
6
apps/desktop/.env.production
Normal file
6
apps/desktop/.env.production
Normal 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
|
||||
76
apps/desktop/electron-builder.yml
Normal file
76
apps/desktop/electron-builder.yml
Normal 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/web(app:// 协议的静态根;不进 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 的 png,electron-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/
|
||||
34
apps/desktop/electron.vite.config.ts
Normal file
34
apps/desktop/electron.vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from 'electron-vite';
|
||||
|
||||
/**
|
||||
* electron-vite 构建配置
|
||||
*
|
||||
* 为什么不配置 renderer:
|
||||
* 渲染层不由本工程构建——开发期直接加载 web-antd 的 vite dev server(http://localhost:14002),
|
||||
* 生产期通过 app:// 自定义协议加载 web-antd 的生产 dist(electron-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 必须是 CommonJS(Electron 硬性限制),
|
||||
// 这里锁定输出为 index.cjs,主进程按该文件名引用
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].cjs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
26913
apps/desktop/out/main/index.js
Normal file
26913
apps/desktop/out/main/index.js
Normal file
File diff suppressed because it is too large
Load Diff
114
apps/desktop/out/preload/index.cjs
Normal file
114
apps/desktop/out/preload/index.cjs
Normal 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 canvas,nativeImage 也不能绘制文字;
|
||||
* 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 = {
|
||||
/** 恒为 true,web 侧用它判断是否运行在 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
29
apps/desktop/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
BIN
apps/desktop/release/.icon-ico/icon.ico
Normal file
BIN
apps/desktop/release/.icon-ico/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
228
apps/desktop/release/builder-debug.yml
Normal file
228
apps/desktop/release/builder-debug.yml
Normal 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
|
||||
8
apps/desktop/release/latest.yml
Normal file
8
apps/desktop/release/latest.yml
Normal 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'
|
||||
17
apps/desktop/resources/entitlements.mac.plist
Normal file
17
apps/desktop/resources/entitlements.mac.plist
Normal 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 基础 entitlements(Electron 运行必需的三件套)。
|
||||
本项目无 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>
|
||||
BIN
apps/desktop/resources/icon.png
Normal file
BIN
apps/desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
12
apps/desktop/src/env.d.ts
vendored
Normal file
12
apps/desktop/src/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
54
apps/desktop/src/main/config.ts
Normal file
54
apps/desktop/src/main/config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 桌面端全局配置常量
|
||||
*
|
||||
* 所有「待确认占位项」集中在本文件(TODO 标注),后续对齐生产环境后只需改这里,
|
||||
* 不要把这些常量散落到其它模块(避免上线前漏改)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 应用唯一标识
|
||||
* 必须与 electron-builder.yml 的 appId 完全一致:
|
||||
* Windows 通知依赖 AppUserModelID(AUMID)与安装包快捷方式的 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.production(TODO:生产域名确认后改该文件)
|
||||
* 转发语义与 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 产物:
|
||||
* Windows:latest.yml + exe + blockmap;mac:latest-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
|
||||
* 注意端口是 14002(apps/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';
|
||||
66
apps/desktop/src/main/index.ts
Normal file
66
apps/desktop/src/main/index.ts
Normal 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();
|
||||
}
|
||||
223
apps/desktop/src/main/ipc.ts
Normal file
223
apps/desktop/src/main/ipc.ts
Normal 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
|
||||
* 实现:隐藏窗口加载 HTML(base64 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();
|
||||
}
|
||||
}
|
||||
223
apps/desktop/src/main/protocol.ts
Normal file
223
apps/desktop/src/main/protocol.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* app:// 自定义协议层——桌面端的「内置 nginx」
|
||||
*
|
||||
* 生产环境渲染进程加载 app://local/,本模块负责三件事:
|
||||
* 1. 静态文件服务:把 web-antd 的生产 dist(extraResources 拷入 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 Context,clipboard 等现代 Web 能力才可用
|
||||
* - supportFetchAPI + corsEnabled:允许渲染层 fetch/XHR 请求本协议(axios 调 /api 必需)
|
||||
* - stream:允许流式响应;codeCache:v8 代码缓存加速二次启动
|
||||
*/
|
||||
export function registerAppSchemePrivileges(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: APP_SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true,
|
||||
codeCache: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 web 静态资源根目录
|
||||
* 打包后:resources/web(electron-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' },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
33
apps/desktop/src/main/shortcut.ts
Normal file
33
apps/desktop/src/main/shortcut.ts
Normal 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());
|
||||
}
|
||||
11
apps/desktop/src/main/state.ts
Normal file
11
apps/desktop/src/main/state.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 主进程共享运行时状态
|
||||
* 单独成模块是为了避免 window.ts / tray.ts / updater.ts 之间互相 import 造成循环依赖
|
||||
*/
|
||||
export const appState = {
|
||||
/**
|
||||
* 是否处于「真正退出」流程(托盘菜单退出 / mac Cmd+Q / 更新重启安装)
|
||||
* false 时点击窗口关闭按钮只隐藏到托盘,不退出应用
|
||||
*/
|
||||
quitting: false,
|
||||
};
|
||||
16
apps/desktop/src/main/store.ts
Normal file
16
apps/desktop/src/main/store.ts
Normal 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' });
|
||||
66
apps/desktop/src/main/tray.ts
Normal file
66
apps/desktop/src/main/tray.ts
Normal 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);
|
||||
}
|
||||
154
apps/desktop/src/main/updater.ts
Normal file
154
apps/desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 自动更新:electron-updater(generic provider,消费静态托管的 latest*.yml + 安装包)
|
||||
* Windows 消费 latest.yml + exe;mac 消费 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);
|
||||
}
|
||||
}
|
||||
35
apps/desktop/src/main/utils.ts
Normal file
35
apps/desktop/src/main/utils.ts
Normal 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();
|
||||
}
|
||||
187
apps/desktop/src/main/window.ts
Normal file
187
apps/desktop/src/main/window.ts
Normal 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 server(localhost 与 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 的 dist(hash 路由,入口固定 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();
|
||||
}
|
||||
}
|
||||
121
apps/desktop/src/preload/index.ts
Normal file
121
apps/desktop/src/preload/index.ts
Normal 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 canvas,nativeImage 也不能绘制文字;
|
||||
* 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 = {
|
||||
/** 恒为 true,web 侧用它判断是否运行在 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;
|
||||
17
apps/desktop/tsconfig.json
Normal file
17
apps/desktop/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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 |
BIN
apps/web-antd/public/logo-horizontal.png
Normal file
BIN
apps/web-antd/public/logo-horizontal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/web-antd/public/logo.png
Normal file
BIN
apps/web-antd/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 443 KiB |
@@ -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、业务码为 401(jexpire),这里需触发重新登录;
|
||||
// 注意不要 return doReAuthenticate() 的 Promise,否则拦截器会把这次失败当成已解决,
|
||||
// 调用方拿不到 reject,故只触发重认证后直接 return,让拦截器维持拒绝语义
|
||||
if (error?.response?.data?.code === 401) {
|
||||
doReAuthenticate();
|
||||
return;
|
||||
}
|
||||
// 当前mock接口返回的错误字段是 error 或者 message
|
||||
const responseData = error?.response?.data ?? {};
|
||||
|
||||
71
apps/web-antd/src/components/glass/glass-card.vue
Normal file
71
apps/web-antd/src/components/glass/glass-card.vue
Normal 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>
|
||||
72
apps/web-antd/src/components/glass/glass-scene.vue
Normal file
72
apps/web-antd/src/components/glass/glass-scene.vue
Normal 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>
|
||||
6
apps/web-antd/src/components/glass/index.ts
Normal file
6
apps/web-antd/src/components/glass/index.ts
Normal 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';
|
||||
@@ -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
77
apps/web-antd/src/types/desktop.d.ts
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Electron 桌面端注入的 window.desktopAPI 全局类型声明
|
||||
* 实现见 apps/desktop/src/preload/index.ts(contextBridge 注入),两边字段必须保持同步;
|
||||
* 浏览器环境下该对象为 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;
|
||||
/** 打印完整 HTML:silent 且有打印机名时静默打印,否则弹系统打印对话框 */
|
||||
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>;
|
||||
};
|
||||
}
|
||||
}
|
||||
95
apps/web-antd/src/util/desktop.ts
Normal file
95
apps/web-antd/src/util/desktop.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
61
apps/web-antd/src/views/media/wechat-account/api/index.ts
Normal file
61
apps/web-antd/src/views/media/wechat-account/api/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
];
|
||||
119
apps/web-antd/src/views/media/wechat-account/config/form.ts
Normal file
119
apps/web-antd/src/views/media/wechat-account/config/form.ts
Normal 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: '公众号原始ID(gh_ 开头,选填)',
|
||||
},
|
||||
fieldName: 'original_id',
|
||||
label: '原始ID',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入 AppID(wx 开头)',
|
||||
},
|
||||
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,
|
||||
};
|
||||
439
apps/web-antd/src/views/media/wechat-account/index.vue
Normal file
439
apps/web-antd/src/views/media/wechat-account/index.vue
Normal 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>
|
||||
112
apps/web-antd/src/views/media/wechat-article/api/index.ts
Normal file
112
apps/web-antd/src/views/media/wechat-article/api/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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('审核仍在进行中,可稍后在列表「刷新发布状态」查看结果');
|
||||
// 这里不直接 notifyPublished:close 会触发 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 图文模块字典:状态值对齐后端 WechatArticleStatusEnum(0草稿 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 颜色(对齐后端 WechatVersionActionEnum:1创建2修改3删除4回退5发布) */
|
||||
export const VERSION_ACTION_COLOR: Record<number, string> = {
|
||||
1: 'success',
|
||||
2: 'blue',
|
||||
3: 'error',
|
||||
4: 'warning',
|
||||
5: 'purple',
|
||||
};
|
||||
@@ -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;输入源是 Markdown(html: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>
|
||||
@@ -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[]];
|
||||
/** 图片上传成功:父组件在光标处插入  */
|
||||
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×2、9 张 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>
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
/** 当前主题 key(v-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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
623
apps/web-antd/src/views/media/wechat-article/editor/index.vue
Normal file
623
apps/web-antd/src/views/media/wechat-article/editor/index.vue
Normal 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();
|
||||
|
||||
/** 当前文章 id:0 表示新建,保存成功后回填并 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) => ``).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 书写正文,右侧实时预览排版效果… ## 小节标题 正文段落,支持 **加粗**、*斜体*、[链接](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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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~1(Rec. 709 加权) */
|
||||
function luminanceOf([r, g, b]: [number, number, number]): number {
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
|
||||
/** 饱和度 0~1(HSV 口径),用来区分「灰阶」与「品牌色」 */
|
||||
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)}"`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 而非 interface:type 拥有隐式索引签名,才能匹配 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
/** 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×2,5=2+3,6=3+3;7 张以上每行 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;
|
||||
|
||||
/**
|
||||
* 渲染相册 HTML:flex 行 + 正方形裁切格子,全部内联样式(公众号安全)
|
||||
* 圆角跟随主题 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>`;
|
||||
}
|
||||
@@ -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;`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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)};`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 自定义主题运行时注册表(「我的模板」)
|
||||
*
|
||||
* 用户以 JSON 形式上传「元素 → 内联 CSS」映射,后端清洗入库(nl_wechat_theme),
|
||||
* 前端拉取后在这里注册成与内置主题同构的 WechatTheme 对象,渲染链路完全复用。
|
||||
*
|
||||
* 设计要点:
|
||||
* - registry 用 reactive Map:getTheme 在 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 → WechatTheme);reactive 保证异步加载后预览自动刷新 */
|
||||
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()];
|
||||
}
|
||||
@@ -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;`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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};`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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;`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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;`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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~1),darken/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 };
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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)};`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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};`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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)};`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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)};`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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;',
|
||||
},
|
||||
};
|
||||
|
||||
/** 制作教程 Markdown(zip 内 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 个模板
|
||||
`;
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/** 主题分类 key(custom 为「我的模板」伪分类:用户导入的自定义主题,不在内置分类列表里) */
|
||||
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;
|
||||
}
|
||||
@@ -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)};`,
|
||||
}),
|
||||
},
|
||||
];
|
||||
515
apps/web-antd/src/views/media/wechat-article/index.vue
Normal file
515
apps/web-antd/src/views/media/wechat-article/index.vue
Normal 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);
|
||||
/** 筛选条件(对齐后端 queryField:title 模糊,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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
1567
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
10
turbo.json
10
turbo.json
@@ -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": [],
|
||||
|
||||
Reference in New Issue
Block a user