diff --git a/jeecgboot-vue3/build/config/themeConfig.ts b/jeecgboot-vue3/build/config/themeConfig.ts index 69e7575c6..a12852a77 100644 --- a/jeecgboot-vue3/build/config/themeConfig.ts +++ b/jeecgboot-vue3/build/config/themeConfig.ts @@ -1,7 +1,7 @@ import { generate } from '@ant-design/colors'; import setting from '/@/settings/projectSetting'; - -// 代码逻辑说明: 【JHHB-579】去掉写死的主题色,根据导航栏模式确定主题色 +// 构建期默认主题色(与 src/settings 中默认 MIX 菜单模式 APP_PRESET_COLOR_LIST[2] 保持一致) +// 运行时会从 store 拿真实值并显式传入,这里只做 build 阶段的占位与默认色板生成 export const primaryColor = setting.themeColor; export const darkMode = setting.themeMode; type Fn = (...arg: any) => any; diff --git a/jeecgboot-vue3/build/vite/plugin/theme-plugin/antdDarkThemePlugin.ts b/jeecgboot-vue3/build/vite/plugin/theme-plugin/antdDarkThemePlugin.ts new file mode 100644 index 000000000..e1bf2c82c --- /dev/null +++ b/jeecgboot-vue3/build/vite/plugin/theme-plugin/antdDarkThemePlugin.ts @@ -0,0 +1,224 @@ +import type { Plugin, ResolvedConfig } from 'vite'; +import path from 'path'; +import fs from 'fs-extra'; +import less from 'less'; +import { createFileHash, minifyCSS, extractVariable } from './utils'; +import chalk from 'chalk'; +import { colorRE, linkID } from './constants'; +import { injectClientPlugin } from './injectClientPlugin'; +import { lessPlugin } from './preprocessor/less'; + +export interface AntdDarkThemeOption { + darkModifyVars?: any; + fileName?: string; + verbose?: boolean; + selector?: string; + filter?: (id: string) => boolean; + extractCss?: boolean; + preloadFiles?: string[]; + loadMethod?: 'link' | 'ajax'; +} + +export function antdDarkThemePlugin(options: AntdDarkThemeOption): Plugin[] { + const { + darkModifyVars, + verbose = true, + fileName = 'app-antd-dark-theme-style', + selector, + filter, + extractCss = true, + preloadFiles = [], + loadMethod = 'link', + } = options; + let isServer = false; + let needSourcemap = false; + let config: ResolvedConfig; + let extCssString = ''; + + const styleMap = new Map(); + const codeCache = new Map(); + + const cssOutputName = `${fileName}.${createFileHash()}.css`; + + const hrefProtocals = ['http://']; + + const getCss = (css: string) => { + return `[${selector || 'data-theme="dark"'}] {${css}}`; + }; + + async function preloadLess() { + if (!preloadFiles || !preloadFiles.length) return; + for (const id of preloadFiles) { + const code = fs.readFileSync(id, 'utf-8'); + less + .render(code, { + javascriptEnabled: true, + modifyVars: darkModifyVars, + filename: path.resolve(id), + plugins: [lessPlugin(id, config)], + }) + .then(({ css }) => { + const colors = css.match(colorRE); + if (colors) { + css = extractVariable(css, colors.concat(['transparent'])); + codeCache.set(id, { code, css }); + } + }) + .catch(() => void 0); + } + } + + function getProtocal(p: string): string | undefined { + let protocal: string | undefined; + hrefProtocals.forEach((hrefProtocal) => { + if (p.startsWith(hrefProtocal)) protocal = hrefProtocal; + }); + return protocal; + } + + return [ + injectClientPlugin('antdDarkPlugin', { + antdDarkCssOutputName: cssOutputName, + antdDarkExtractCss: extractCss, + antdDarkLoadLink: loadMethod === 'link', + }), + { + name: 'vite:antd-dark-theme', + enforce: 'pre', + configResolved(resolvedConfig) { + config = resolvedConfig; + isServer = resolvedConfig.command === 'serve'; + needSourcemap = !!resolvedConfig.build.sourcemap; + if (isServer) preloadLess(); + }, + transformIndexHtml: { + order: 'pre', + handler(html) { + let href: string; + const protocal = getProtocal(config.base); + + if (isServer || loadMethod !== 'link') return html; + + if (protocal) { + href = + protocal + + path.posix.join( + config.base.slice(protocal.length), + config.build.assetsDir, + cssOutputName, + ); + } else { + href = path.posix.join(config.base, config.build.assetsDir, cssOutputName); + } + + return { + html, + tags: [ + { + tag: 'link', + attrs: { + disabled: true, + id: linkID, + rel: 'alternate stylesheet', + href, + }, + injectTo: 'head', + }, + ], + }; + }, + }, + + async transform(code, id) { + if (!id.endsWith('.less') || !code.includes('@')) return null; + if (typeof filter === 'function' && !filter(id)) return null; + + const getResult = (content: string) => ({ + map: needSourcemap ? this.getCombinedSourcemap() : null, + code: content, + }); + + let processCss = ''; + const cache = codeCache.get(id); + const isUpdate = !cache || cache.code !== code; + + if (isUpdate) { + let renderedCss = ''; + try { + const { css } = await less.render(code, { + javascriptEnabled: true, + modifyVars: darkModifyVars, + filename: path.resolve(id), + plugins: [lessPlugin(id, config)], + }); + renderedCss = css; + } catch { + // less 解析失败(典型场景:业务 less 用了 vite alias / virtual import), + // 静默跳过该文件 —— 不影响主流程,也不参与 dark theme 提取。 + return null; + } + const colors = renderedCss.match(colorRE); + if (colors) { + processCss = extractVariable(renderedCss, colors.concat(['transparent'])); + } + } else { + processCss = cache!.css; + } + + if (isServer || !extractCss) { + if (isUpdate) codeCache.set(id, { code, css: processCss }); + return getResult(`${getCss(processCss)}\n` + code); + } else { + if (!styleMap.has(id) && processCss) { + try { + const { css } = await less.render(getCss(processCss), { + filename: path.resolve(id), + plugins: [lessPlugin(id, config)], + }); + extCssString += `${css}\n`; + } catch { + // 同上:拼出来的 selector wrapper less 解析失败也跳过 + } + } + styleMap.set(id, processCss); + } + + return null; + }, + + async writeBundle() { + if (!extractCss) return; + const { + root, + build: { outDir, assetsDir, minify }, + } = config; + if (minify) { + extCssString = await minifyCSS(extCssString, config); + } + const cssOutputPath = path.resolve(root, outDir, assetsDir, cssOutputName); + fs.writeFileSync(cssOutputPath, extCssString); + }, + + closeBundle() { + if (verbose && !isServer && extractCss) { + const { + build: { outDir, assetsDir }, + } = config; + console.log( + chalk.cyan('\n✨ [vite-plugin-theme:antd-dark]') + + ` - extract antd dark css code file is successfully:`, + ); + try { + const { size } = fs.statSync(path.join(outDir, assetsDir, cssOutputName)); + console.log( + chalk.dim(outDir + '/') + + chalk.magentaBright(`${assetsDir}/${cssOutputName}`) + + `\t\t${chalk.dim((size / 1024).toFixed(2) + 'kb')}` + + '\n', + ); + } catch {} + } + }, + }, + ]; +} diff --git a/jeecgboot-vue3/build/vite/plugin/theme-plugin/client/client.ts b/jeecgboot-vue3/build/vite/plugin/theme-plugin/client/client.ts new file mode 100644 index 000000000..e1e4375ad --- /dev/null +++ b/jeecgboot-vue3/build/vite/plugin/theme-plugin/client/client.ts @@ -0,0 +1,191 @@ +// @ts-nocheck +// 以下 6 个 placeholder 标识符由 build/vite/plugin/theme-plugin/injectClientPlugin.ts +// 在 transform hook 中以正则单词边界字符串替换为最终值。 +// 不要写 `declare const __X__: T` —— 替换会破坏 declare 语句结构。 +// vite 用 oxc/esbuild 编译 .ts 不做严格类型检查,未声明标识符能编过。 + +export const globalField = '__VITE_THEME__'; +export const styleTagId = '__VITE_PLUGIN_THEME__'; +export const darkStyleTagId = '__VITE_PLUGIN_DARK_THEME__'; +export const linkID = '__VITE_PLUGIN_THEME-ANTD_DARK_THEME_LINK__'; + +const colorPluginOutputFileName = __COLOR_PLUGIN_OUTPUT_FILE_NAME__; +const isProd = __PROD__; +const colorPluginOptions = __COLOR_PLUGIN_OPTIONS__; +const injectTo = colorPluginOptions && colorPluginOptions.injectTo; + +const debounceThemeRender = debounce(200, renderTheme); + +export let darkCssIsReady = false; + +(() => { + if (!(window as any)[globalField]) { + (window as any)[globalField] = { + styleIdMap: new Map(), + styleRenderQueueMap: new Map(), + }; + } + setGlobalOptions('replaceStyleVariables', replaceStyleVariables); + if (!getGlobalOptions('defaultOptions')) { + setGlobalOptions('defaultOptions', colorPluginOptions); + } +})(); + +export function addCssToQueue(id: string, styleString: string) { + const styleIdMap: Map = getGlobalOptions('styleIdMap'); + if (!styleIdMap.get(id)) { + (window as any)[globalField].styleRenderQueueMap.set(id, styleString); + debounceThemeRender(); + } +} + +function renderTheme() { + const variables = getGlobalOptions('colorVariables'); + if (!variables) return; + const styleRenderQueueMap: Map = getGlobalOptions('styleRenderQueueMap'); + const styleDom = getStyleDom(styleTagId); + let html = styleDom.innerHTML; + for (const [id, css] of styleRenderQueueMap.entries()) { + html += css; + (window as any)[globalField].styleRenderQueueMap.delete(id); + (window as any)[globalField].styleIdMap.set(id, css); + } + replaceCssColors(html, variables).then((processCss) => { + appendCssToDom(styleDom, processCss, injectTo); + }); +} + +export async function replaceStyleVariables({ + colorVariables, + customCssHandler, +}: { + colorVariables: string[]; + customCssHandler?: (css: string) => string; +}) { + setGlobalOptions('colorVariables', colorVariables); + const styleIdMap: Map = getGlobalOptions('styleIdMap'); + const styleRenderQueueMap: Map = getGlobalOptions('styleRenderQueueMap'); + if (!isProd) { + for (const [id, css] of styleIdMap.entries()) { + styleRenderQueueMap.set(id, css); + } + renderTheme(); + } else { + try { + const cssText = await fetchCss(colorPluginOutputFileName); + const styleDom = getStyleDom(styleTagId); + const processCss = await replaceCssColors(cssText, colorVariables, customCssHandler); + appendCssToDom(styleDom, processCss, injectTo); + } catch (error: any) { + throw new Error(error); + } + } +} + +export async function loadDarkThemeCss() { + if (darkCssIsReady || !__ANTD_DARK_PLUGIN_EXTRACT_CSS__) return; + if (__ANTD_DARK_PLUGIN_LOAD_LINK__) { + const linkTag = document.getElementById(linkID); + if (linkTag) { + linkTag.removeAttribute('disabled'); + linkTag.setAttribute('rel', 'stylesheet'); + } + } else { + const cssText = await fetchCss(__ANTD_DARK_PLUGIN_OUTPUT_FILE_NAME__); + const styleDom = getStyleDom(darkStyleTagId); + appendCssToDom(styleDom, cssText, injectTo); + } + darkCssIsReady = true; +} + +export async function replaceCssColors( + css: string, + colors: string[], + customCssHandler?: (css: string) => string, +) { + let retCss = css; + const defaultOptions = getGlobalOptions('defaultOptions'); + const colorVariables: string[] = defaultOptions ? defaultOptions.colorVariables || [] : []; + colorVariables.forEach(function (color, index) { + const reg = new RegExp( + color.replace(/,/g, ',\\s*').replace(/\s/g, '').replace('(', `\\(`).replace(')', `\\)`) + + '([\\da-f]{2})?(\\b|\\)|,|\\s)?', + 'ig', + ); + retCss = retCss.replace(reg, colors[index] + '$1$2').replace('$1$2', ''); + if (customCssHandler && typeof customCssHandler === 'function') { + retCss = customCssHandler(retCss) || retCss; + } + }); + return retCss; +} + +export function setGlobalOptions(key: string, value: any) { + (window as any)[globalField][key] = value; +} + +export function getGlobalOptions(key: string) { + return (window as any)[globalField][key]; +} + +export function getStyleDom(id: string): HTMLStyleElement { + let style = document.getElementById(id) as HTMLStyleElement | null; + if (!style) { + style = document.createElement('style'); + style.setAttribute('id', id); + } + return style; +} + +export async function appendCssToDom( + styleDom: HTMLStyleElement, + cssText: string, + appendTo: 'head' | 'body' | 'body-prepend' = 'body', +) { + styleDom.innerHTML = cssText; + if (appendTo === 'head') { + document.head.appendChild(styleDom); + } else if (appendTo === 'body') { + document.body.appendChild(styleDom); + } else if (appendTo === 'body-prepend') { + const firstChildren = document.body.firstChild; + document.body.insertBefore(styleDom, firstChildren); + } +} + +function fetchCss(fileName: string): Promise { + return new Promise((resolve, reject) => { + const append = getGlobalOptions('appended'); + if (append) { + setGlobalOptions('appended', false); + resolve(''); + return; + } + const xhr = new XMLHttpRequest(); + xhr.onload = function () { + if (xhr.readyState === 4) { + if (xhr.status === 200) resolve(xhr.responseText); + else reject(xhr.status); + } + }; + xhr.onerror = function (e) { + reject(e); + }; + xhr.ontimeout = function (e) { + reject(e); + }; + xhr.open('GET', fileName, true); + xhr.send(); + }); +} + +function debounce any>(delay: number, fn: T) { + let timer: ReturnType | undefined; + return function (this: any, ...args: any[]) { + const ctx = this; + if (timer) clearTimeout(timer); + timer = setTimeout(function () { + fn.apply(ctx, args); + }, delay); + }; +} diff --git a/jeecgboot-vue3/build/vite/plugin/theme-plugin/client/colorUtils.ts b/jeecgboot-vue3/build/vite/plugin/theme-plugin/client/colorUtils.ts new file mode 100644 index 000000000..2c9ffec2c --- /dev/null +++ b/jeecgboot-vue3/build/vite/plugin/theme-plugin/client/colorUtils.ts @@ -0,0 +1,50 @@ +import tinycolor from 'tinycolor2'; + +export function mixLighten(colorStr: string, weight: number) { + return mix('fff', colorStr, weight); +} + +export function mixDarken(colorStr: string, weight: number) { + return mix('000', colorStr, weight); +} + +export function mix(color1: string, color2: string, weight: number, alpha1?: number, alpha2?: number) { + color1 = dropPrefix(color1); + color2 = dropPrefix(color2); + if (weight === undefined) weight = 0.5; + if (alpha1 === undefined) alpha1 = 1; + if (alpha2 === undefined) alpha2 = 1; + const w = 2 * weight - 1; + const alphaDelta = alpha1 - alpha2; + const w1 = ((w * alphaDelta === -1 ? w : (w + alphaDelta) / (1 + w * alphaDelta)) + 1) / 2; + const w2 = 1 - w1; + const rgb1 = toNum3(color1); + const rgb2 = toNum3(color2); + const r = Math.round(w1 * rgb1[0] + w2 * rgb2[0]); + const g = Math.round(w1 * rgb1[1] + w2 * rgb2[1]); + const b = Math.round(w1 * rgb1[2] + w2 * rgb2[2]); + return '#' + pad2(r) + pad2(g) + pad2(b); +} + +export function toNum3(colorStr: string) { + colorStr = dropPrefix(colorStr); + if (colorStr.length === 3) { + colorStr = colorStr[0] + colorStr[0] + colorStr[1] + colorStr[1] + colorStr[2] + colorStr[2]; + } + const r = parseInt(colorStr.slice(0, 2), 16); + const g = parseInt(colorStr.slice(2, 4), 16); + const b = parseInt(colorStr.slice(4, 6), 16); + return [r, g, b]; +} + +export function dropPrefix(colorStr: string) { + return colorStr.replace('#', ''); +} + +export function pad2(num: number) { + let t = num.toString(16); + if (t.length === 1) t = '0' + t; + return t; +} + +export { tinycolor }; diff --git a/jeecgboot-vue3/build/vite/plugin/theme-plugin/constants.ts b/jeecgboot-vue3/build/vite/plugin/theme-plugin/constants.ts new file mode 100644 index 000000000..858ece10d --- /dev/null +++ b/jeecgboot-vue3/build/vite/plugin/theme-plugin/constants.ts @@ -0,0 +1,36 @@ +import path from 'path'; +import { normalizePath } from 'vite'; + +export const VITE_CLIENT_ENTRY = '/@vite/client'; + +// 客户端入口(指向项目内置的 client.ts —— 预构建后会落到 node_modules/.vite 缓存里) +// 这里给一个虚拟绝对路径,injectClientPlugin 用它来匹配 transform 的 id +export const VITE_PLUGIN_THEME_CLIENT_ENTRY = normalizePath( + path.resolve(process.cwd(), 'build/vite/plugin/theme-plugin/client'), +); + +export const CLIENT_PUBLIC_ABSOLUTE_PATH = normalizePath( + VITE_PLUGIN_THEME_CLIENT_ENTRY + '/client.ts', +); + +// 业务 import 时使用的相对路径片段(按 normalizePath 规范化),用于宽匹配 +export const CLIENT_PUBLIC_PATH_FRAGMENT = 'theme-plugin/client/client'; + +export const commentRE = /\\\\?n|\n|\\\\?r|\/\*[\s\S]+?\*\//g; + +const cssLangs = `\\.(css|less|sass|scss|styl|stylus|postcss)($|\\?)`; + +export const colorRE = + /#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})|rgba?\((.*),\s*(.*),\s*(.*)(?:,\s*(.*(?:.*)?))?\)/gi; + +export const cssVariableString = `const css = "`; + +export const cssBlockRE = /[^}]*\{[^{]*\}/g; + +export const cssLangRE = new RegExp(cssLangs); +export const ruleRE = /(\w+-)*\w+:/; +export const cssValueRE = /(\s?[a-z0-9]+\s)*/; +export const safeEmptyRE = /\s?/; +export const importSafeRE = /(\s*!important)?/; + +export const linkID = '__VITE_PLUGIN_THEME-ANTD_DARK_THEME_LINK__'; diff --git a/jeecgboot-vue3/build/vite/plugin/theme-plugin/index.ts b/jeecgboot-vue3/build/vite/plugin/theme-plugin/index.ts new file mode 100644 index 000000000..131184b70 --- /dev/null +++ b/jeecgboot-vue3/build/vite/plugin/theme-plugin/index.ts @@ -0,0 +1,185 @@ +import type { Plugin, ResolvedConfig } from 'vite'; +import path from 'path'; +import fs from 'fs-extra'; +import { debug as Debug } from 'debug'; +import chalk from 'chalk'; +import { extractVariable, minifyCSS, createFileHash, formatCss } from './utils'; +import { VITE_CLIENT_ENTRY, cssLangRE, cssVariableString, CLIENT_PUBLIC_ABSOLUTE_PATH } from './constants'; +import { injectClientPlugin } from './injectClientPlugin'; + +export * from './client/colorUtils'; +export { antdDarkThemePlugin } from './antdDarkThemePlugin'; + +export type ResolveSelector = (selector: string) => string; +export type InjectTo = 'head' | 'body' | 'body-prepend'; + +export interface ViteThemeOptions { + colorVariables: string[]; + wrapperCssSelector?: string; + resolveSelector?: ResolveSelector; + customerExtractVariable?: (code: string) => string; + fileName?: string; + injectTo?: InjectTo; + verbose?: boolean; +} + +const debug = Debug('vite-plugin-theme'); + +export function viteThemePlugin(opt: ViteThemeOptions): Plugin[] { + let isServer = false; + let config: ResolvedConfig; + let clientPath = ''; + const styleMap = new Map(); + const extCssSet = new Set(); + + const emptyPlugin: Plugin = { name: 'vite:theme' }; + + const options: ViteThemeOptions = Object.assign( + { + colorVariables: [], + wrapperCssSelector: '', + fileName: 'app-theme-style', + injectTo: 'body', + verbose: true, + }, + opt, + ); + + debug('plugin options:', options); + + const { + colorVariables, + wrapperCssSelector, + resolveSelector, + customerExtractVariable, + fileName, + verbose, + } = options; + + if (!colorVariables || colorVariables.length === 0) { + console.error('colorVariables is not empty!'); + return [emptyPlugin]; + } + + const resolveSelectorFn = + resolveSelector || ((s: string) => `${wrapperCssSelector} ${s}`); + + const cssOutputName = `${fileName}.${createFileHash()}.css`; + + let needSourcemap = false; + + return [ + injectClientPlugin('colorPlugin', { + colorPluginCssOutputName: cssOutputName, + colorPluginOptions: options, + }), + { + ...emptyPlugin, + enforce: 'post', + configResolved(resolvedConfig) { + config = resolvedConfig; + isServer = resolvedConfig.command === 'serve'; + // dev 模式直接通过 /@fs/ 让浏览器加载内置 client.ts + clientPath = JSON.stringify( + isServer + ? '/@fs/' + CLIENT_PUBLIC_ABSOLUTE_PATH + : path.posix.join(config.base, CLIENT_PUBLIC_ABSOLUTE_PATH), + ); + needSourcemap = !!resolvedConfig.build.sourcemap; + debug('plugin config base:', resolvedConfig.base); + }, + + async transform(code, id) { + if (!cssLangRE.test(id)) return null; + + const getResult = (content: string) => ({ + map: needSourcemap ? this.getCombinedSourcemap() : null, + code: content, + }); + + const clientCode = isServer + ? await getClientStyleString(code) + : code.replace('export default', '').replace('"', ''); + + const extractCssCodeTemplate = + typeof customerExtractVariable === 'function' + ? customerExtractVariable(clientCode) + : extractVariable(clientCode, colorVariables, resolveSelectorFn); + + debug('extractCssCodeTemplate:', id, extractCssCodeTemplate?.slice(0, 100)); + + if (!extractCssCodeTemplate) return null; + + if (isServer) { + const retCode = [ + `import { addCssToQueue } from ${clientPath}`, + `const themeCssId = ${JSON.stringify(id)}`, + `const themeCssStr = ${JSON.stringify(formatCss(extractCssCodeTemplate))}`, + `addCssToQueue(themeCssId, themeCssStr)`, + code, + ]; + return getResult(retCode.join('\n')); + } else { + if (!styleMap.has(id)) { + extCssSet.add(extractCssCodeTemplate); + } + styleMap.set(id, extractCssCodeTemplate); + } + + return null; + }, + + async writeBundle() { + const { + root, + build: { outDir, assetsDir, minify }, + } = config; + let extCssString = ''; + for (const css of extCssSet) { + extCssString += css; + } + if (minify) { + extCssString = await minifyCSS(extCssString, config); + } + const cssOutputPath = path.resolve(root, outDir, assetsDir, cssOutputName); + fs.ensureDirSync(path.dirname(cssOutputPath)); + fs.writeFileSync(cssOutputPath, extCssString); + }, + + closeBundle() { + if (verbose && !isServer) { + const { + build: { outDir, assetsDir }, + } = config; + console.log( + chalk.cyan('\n✨ [vite-plugin-theme]') + + ` - extract css code file is successfully:`, + ); + try { + const { size } = fs.statSync(path.join(outDir, assetsDir, cssOutputName)); + console.log( + chalk.dim(outDir + '/') + + chalk.magentaBright(`${assetsDir}/${cssOutputName}`) + + `\t\t${chalk.dim((size / 1024).toFixed(2) + 'kb')}` + + '\n', + ); + } catch {} + } + }, + }, + ]; +} + +async function getClientStyleString(code: string) { + if (!code.includes(VITE_CLIENT_ENTRY)) return code; + code = code.replace(/\\n/g, ''); + const cssPrefix = cssVariableString; + const cssPrefixLen = cssPrefix.length; + const cssPrefixIndex = code.indexOf(cssPrefix); + const len = cssPrefixIndex + cssPrefixLen; + const cssLastIndex = code.indexOf('\n', len + 1); + if (cssPrefixIndex !== -1) { + code = code.slice(len, cssLastIndex); + } + return code; +} diff --git a/jeecgboot-vue3/build/vite/plugin/theme-plugin/injectClientPlugin.ts b/jeecgboot-vue3/build/vite/plugin/theme-plugin/injectClientPlugin.ts new file mode 100644 index 000000000..6fcfdbd5f --- /dev/null +++ b/jeecgboot-vue3/build/vite/plugin/theme-plugin/injectClientPlugin.ts @@ -0,0 +1,94 @@ +import path from 'path'; +import { normalizePath } from 'vite'; +import type { Plugin, ResolvedConfig } from 'vite'; +import type { ViteThemeOptions } from './index'; +import { CLIENT_PUBLIC_ABSOLUTE_PATH, CLIENT_PUBLIC_PATH_FRAGMENT } from './constants'; + +type PluginType = 'colorPlugin' | 'antdDarkPlugin'; + +/** + * 通过 transform hook 直接对内置 client.ts 中的 6 个占位符做字符串替换。 + * + * vite 8 的 `define` 在 dev 模式下并未对项目源码(.ts)中的纯标识符占位做编译期替换, + * 因此回到 transform-based 替换 —— 与原 @rys-fe/vite-plugin-theme 行为一致。 + * + * 同名 plugin 注册多次(colorPlugin + antdDarkPlugin)时,两次 transform 都会跑, + * 各自只替换自己负责的占位,互不干扰。 + */ +export function injectClientPlugin( + type: PluginType, + { + colorPluginOptions, + colorPluginCssOutputName, + antdDarkCssOutputName, + antdDarkExtractCss, + antdDarkLoadLink, + }: { + colorPluginOptions?: ViteThemeOptions; + antdDarkCssOutputName?: string; + colorPluginCssOutputName?: string; + antdDarkExtractCss?: boolean; + antdDarkLoadLink?: boolean; + }, +): Plugin { + let config: ResolvedConfig; + let isServer = false; + + return { + name: `vite:inject-vite-plugin-theme-client(${type})`, + enforce: 'pre', + configResolved(resolvedConfig) { + config = resolvedConfig; + isServer = resolvedConfig.command === 'serve'; + }, + + transform(code, id) { + const nid = normalizePath(id); + const isClientFile = + nid === CLIENT_PUBLIC_ABSOLUTE_PATH || + nid.endsWith(CLIENT_PUBLIC_PATH_FRAGMENT + '.ts') || + nid.endsWith(CLIENT_PUBLIC_PATH_FRAGMENT + '.js') || + nid.includes(CLIENT_PUBLIC_PATH_FRAGMENT.replace(/\//gi, '_')); + + if (!isClientFile) return; + + const assetsDir = config.build?.assetsDir ?? 'assets'; + const base = config.base ?? '/'; + const getOutputFile = (name?: string) => + JSON.stringify(`${base}${assetsDir}/${name}`); + + // __PROD__: 所有 plugin 实例都注入 + code = code.replace(/\b__PROD__\b/g, JSON.stringify(!isServer)); + + if (type === 'colorPlugin') { + code = code + .replace(/\b__COLOR_PLUGIN_OUTPUT_FILE_NAME__\b/g, getOutputFile(colorPluginCssOutputName)) + .replace(/\b__COLOR_PLUGIN_OPTIONS__\b/g, JSON.stringify(colorPluginOptions ?? {})); + } + + if (type === 'antdDarkPlugin') { + code = code.replace( + /\b__ANTD_DARK_PLUGIN_OUTPUT_FILE_NAME__\b/g, + getOutputFile(antdDarkCssOutputName), + ); + if (typeof antdDarkExtractCss === 'boolean') { + code = code.replace( + /\b__ANTD_DARK_PLUGIN_EXTRACT_CSS__\b/g, + JSON.stringify(antdDarkExtractCss), + ); + } + if (typeof antdDarkLoadLink === 'boolean') { + code = code.replace( + /\b__ANTD_DARK_PLUGIN_LOAD_LINK__\b/g, + JSON.stringify(antdDarkLoadLink), + ); + } + } + + return { code, map: null }; + }, + }; +} + +// 防止 path 被 tree-shake +export const _PATH = path.posix; diff --git a/jeecgboot-vue3/build/vite/plugin/theme-plugin/preprocessor/less/index.ts b/jeecgboot-vue3/build/vite/plugin/theme-plugin/preprocessor/less/index.ts new file mode 100644 index 000000000..112ff079f --- /dev/null +++ b/jeecgboot-vue3/build/vite/plugin/theme-plugin/preprocessor/less/index.ts @@ -0,0 +1,147 @@ +import path from 'path'; +import fs from 'fs'; +import type { Alias, ResolvedConfig } from 'vite'; +import { normalizePath } from 'vite'; +import less from 'less'; + +export type ResolveFn = ( + id: string, + importer?: string, + aliasOnly?: boolean, +) => Promise; + +type CssUrlReplacer = (url: string, importer?: string) => string | Promise; + +export const externalRE = /^(https?:)?\/\//; +export const isExternalUrl = (url: string) => externalRE.test(url); + +export const dataUrlRE = /^\s*data:/i; +export const isDataUrl = (url: string) => dataUrlRE.test(url); + +const cssUrlRE = /url\(\s*('[^']+'|"[^"]+"|[^'")]+)\s*\)/; + +let ViteLessManager: any; + +function createViteLessPlugin( + rootFile: string, + alias: Alias[], + resolvers: { less: ResolveFn }, +): Less.Plugin { + if (!ViteLessManager) { + ViteLessManager = class ViteManager extends (less as any).FileManager { + resolvers: any; + rootFile: string; + alias: Alias[]; + constructor(rootFile: string, resolvers: { less: ResolveFn }, alias: Alias[]) { + super(); + this.rootFile = rootFile; + this.resolvers = resolvers; + this.alias = alias; + } + supports() { + return true; + } + supportsSync() { + return false; + } + async loadFile(filename: string, dir: string, opts: any, env: any) { + const resolved = await this.resolvers.less(filename, path.join(dir, '*')); + if (resolved) { + const result = await rebaseUrls(resolved, this.rootFile, this.alias); + let contents: string; + if (result && 'contents' in result) { + contents = result.contents as string; + } else { + contents = fs.readFileSync(resolved, 'utf-8'); + } + return { + filename: path.resolve(resolved), + contents, + }; + } else { + return super.loadFile(filename, dir, opts, env); + } + } + }; + } + + return { + install(_: any, pluginManager: any) { + pluginManager.addFileManager(new ViteLessManager(rootFile, resolvers, alias)); + }, + minVersion: [3, 0, 0], + }; +} + +export function lessPlugin(id: string, config: ResolvedConfig) { + const resolvers = createCSSResolvers(config); + return createViteLessPlugin(id, (config.resolve as any).alias as Alias[], resolvers); +} + +// vite 8 中 ResolvedConfig.createResolver 已经被环境化(依赖 config.environments), +// 直接调用会抛 "Cannot read properties of undefined (reading 'environments')"。 +// antd dark theme 处理的是 less 源码,业务 less 中的 alias import 已由上游 vite 流水线处理过; +// 这里只用 less 默认 FileManager(相对路径),少数 alias import 失败时由 antdDark transform 的 +// try/catch 捕获并跳过,不阻塞 build。 +function createCSSResolvers(_config: ResolvedConfig): { less: ResolveFn } { + return { + less: (async (_id: string) => undefined) as ResolveFn, + }; +} + +async function rebaseUrls(file: string, rootFile: string, alias: Alias[]): Promise { + file = path.resolve(file); + const fileDir = path.dirname(file); + const rootDir = path.dirname(rootFile); + if (fileDir === rootDir) return { file }; + const content = fs.readFileSync(file, 'utf-8'); + if (!cssUrlRE.test(content)) return { file }; + const rebased = await rewriteCssUrls(content, (url) => { + if (url.startsWith('/')) return url; + for (const { find } of alias) { + const matches = typeof find === 'string' ? url.startsWith(find) : (find as RegExp).test(url); + if (matches) return url; + } + const absolute = path.resolve(fileDir, url); + const relative = path.relative(rootDir, absolute); + return normalizePath(relative); + }); + return { file, contents: rebased }; +} + +function rewriteCssUrls(css: string, replacer: CssUrlReplacer): Promise { + return asyncReplace(css, cssUrlRE, async (match) => { + const [matched, rawUrl] = match; + return await doUrlReplace(rawUrl, matched, replacer); + }); +} + +export async function asyncReplace( + input: string, + re: RegExp, + replacer: (match: RegExpExecArray) => string | Promise, +) { + let match: RegExpExecArray | null; + let remaining = input; + let rewritten = ''; + while ((match = re.exec(remaining))) { + rewritten += remaining.slice(0, match.index); + rewritten += await replacer(match); + remaining = remaining.slice(match.index + match[0].length); + } + rewritten += remaining; + return rewritten; +} + +async function doUrlReplace(rawUrl: string, matched: string, replacer: CssUrlReplacer) { + let wrap = ''; + const first = rawUrl[0]; + if (first === `"` || first === `'`) { + wrap = first; + rawUrl = rawUrl.slice(1, -1); + } + if (isExternalUrl(rawUrl) || isDataUrl(rawUrl) || rawUrl.startsWith('#')) { + return matched; + } + return `url(${wrap}${await replacer(rawUrl)}${wrap})`; +} diff --git a/jeecgboot-vue3/build/vite/plugin/theme-plugin/utils.ts b/jeecgboot-vue3/build/vite/plugin/theme-plugin/utils.ts new file mode 100644 index 000000000..89a8c6868 --- /dev/null +++ b/jeecgboot-vue3/build/vite/plugin/theme-plugin/utils.ts @@ -0,0 +1,129 @@ +import type { ResolvedConfig } from 'vite'; +import { createHash } from 'crypto'; +import CleanCSS from 'clean-css'; +import { + commentRE, + cssBlockRE, + ruleRE, + cssValueRE, + safeEmptyRE, + importSafeRE, +} from './constants'; + +export type ResolveSelector = (selector: string) => string; + +export function getVariablesReg(colors: string[]) { + return new RegExp( + colors + .map( + (i) => + `(${i + .replace(/\s/g, ' ?') + .replace(/\(/g, `\\(`) + .replace(/\)/g, `\\)`) + .replace(/0?\./g, `0?\\.`)})`, + ) + .join('|'), + ); +} + +export function combineRegs(decorator = '', joinString = '', ...args: any[]) { + const regString = args + .map((item) => { + const str = item.toString(); + return `(${str.slice(1, str.length - 1)})`; + }) + .join(joinString); + return new RegExp(regString, decorator); +} + +export function formatCss(s: string) { + s = s.replace(/\s*([{}:;,])\s*/g, '$1'); + s = s.replace(/;\s*;/g, ';'); + s = s.replace(/,[\s.#\d]*{/g, '{'); + s = s.replace(/([^\s])\{([^\s])/g, '$1 {\n\t$2'); + s = s.replace(/([^\s])\}([^\n]*)/g, '$1\n}\n$2'); + s = s.replace(/([^\s]);([^\s}])/g, '$1;\n\t$2'); + return s; +} + +export function createFileHash() { + return createHash('sha256').update(Date.now().toString()).digest('hex').substr(0, 8); +} + +/** + * Compress the generated css. + * vite 8 已移除 config.build.cleanCssOptions,这里直接使用 CleanCSS 默认配置。 + */ +export async function minifyCSS(css: string, config: ResolvedConfig) { + const res = new CleanCSS({ rebase: false }).minify(css); + + if (res.errors && res.errors.length) { + console.error(`error when minifying css:\n${res.errors}`); + throw res.errors[0]; + } + + if (res.warnings && res.warnings.length) { + config.logger.warn(`warnings when minifying css:\n${res.warnings}`); + } + + return res.styles; +} + +// Used to extract relevant color configuration in css +export function extractVariable( + code: string, + colorVariables: string[], + resolveSelector?: ResolveSelector, + colorRE?: RegExp, +): string { + colorVariables = Array.from(new Set(colorVariables)); + code = code.replace(commentRE, ''); + + const cssBlocks = code.match(cssBlockRE); + if (!cssBlocks || cssBlocks.length === 0) { + return ''; + } + + let allExtractedVariable = ''; + + const variableReg = getVariablesReg(colorVariables); + + for (let index = 0; index < cssBlocks.length; index++) { + const cssBlock = cssBlocks[index]; + if (!variableReg.test(cssBlock) || !cssBlock) continue; + + const cssSelector = cssBlock.match(/[^{]*/)?.[0] ?? ''; + if (!cssSelector) continue; + + if (/^@.*keyframes/.test(cssSelector)) { + allExtractedVariable += `${cssSelector}{${extractVariable( + cssBlock.replace(/[^{]*\{/, '').replace(/}$/, ''), + colorVariables, + resolveSelector, + colorRE, + )}}`; + continue; + } + + const colorReg = combineRegs( + 'g', + '', + ruleRE, + cssValueRE, + safeEmptyRE, + variableReg, + importSafeRE, + ); + + const colorReplaceTemplates = cssBlock.match(colorRE || colorReg); + + if (!colorReplaceTemplates) continue; + + allExtractedVariable += `${ + resolveSelector ? resolveSelector(cssSelector) : cssSelector + } {${colorReplaceTemplates.join(';')}}`; + } + + return allExtractedVariable; +} diff --git a/jeecgboot-vue3/build/vite/plugin/theme.ts b/jeecgboot-vue3/build/vite/plugin/theme.ts index 542769daa..626ed593b 100644 --- a/jeecgboot-vue3/build/vite/plugin/theme.ts +++ b/jeecgboot-vue3/build/vite/plugin/theme.ts @@ -4,7 +4,8 @@ */ import type { PluginOption } from 'vite'; import path from 'path'; -import { viteThemePlugin, antdDarkThemePlugin, mixLighten, mixDarken, tinycolor } from '@rys-fe/vite-plugin-theme'; +// 项目内置版本(适配 vite 8):原 @rys-fe/vite-plugin-theme 不再适配 vite 8,已搬入 ./theme-plugin +import { viteThemePlugin, antdDarkThemePlugin, mixLighten, mixDarken, tinycolor } from './theme-plugin'; import { getThemeColors, generateColors } from '../../config/themeConfig'; import { generateModifyVars } from '../../generate/generateModifyVars'; diff --git a/jeecgboot-vue3/package.json b/jeecgboot-vue3/package.json index 571adba1f..7e1b9ce7f 100644 --- a/jeecgboot-vue3/package.json +++ b/jeecgboot-vue3/package.json @@ -112,8 +112,8 @@ "@types/sortablejs": "^1.15.8", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-vue": "5.2.4", - "@vitejs/plugin-vue-jsx": "4.1.1", + "@vitejs/plugin-vue": "^6.0.6", + "@vitejs/plugin-vue-jsx": "^5.1.5", "@vue/compiler-sfc": "^3.5.22", "@vue/test-utils": "^2.4.6", "autoprefixer": "^10.4.21", @@ -158,7 +158,7 @@ "ts-node": "^10.9.2", "typescript": "^5.9.3", "unplugin-vue-components": "~0.24.1", - "vite": "^6.3.6", + "vite": "^7.3.3", "vite-plugin-compression": "^0.5.1", "vite-plugin-html": "^3.2.2", "vite-plugin-mkcert": "^1.17.9", @@ -170,9 +170,8 @@ "vite-plugin-pwa": "^1.1.0", "workbox-window": "^7.3.0", "vite-plugin-qiankun": "^1.0.15", - "@rys-fe/vite-plugin-theme": "^0.8.6", "vite-plugin-vue-setup-extend-plus": "^0.1.0", - "unocss": "^0.58.9", + "unocss": "^66.6.8", "vue-eslint-parser": "^9.4.3", "vue-tsc": "^1.8.27", "dingtalk-jsapi": "^3.2.0", diff --git a/jeecgboot-vue3/tsconfig.json b/jeecgboot-vue3/tsconfig.json index a8ac696f2..18b6cdf07 100644 --- a/jeecgboot-vue3/tsconfig.json +++ b/jeecgboot-vue3/tsconfig.json @@ -22,6 +22,9 @@ "noImplicitAny": false, "skipLibCheck": true, "paths": { + "@rys-fe/vite-plugin-theme/es/client": ["build/vite/plugin/theme-plugin/client/client.ts"], + "@rys-fe/vite-plugin-theme/es/colorUtils": ["build/vite/plugin/theme-plugin/client/colorUtils.ts"], + "@rys-fe/vite-plugin-theme": ["build/vite/plugin/theme-plugin/index.ts"], "/@/*": ["src/*"], "/#/*": ["types/*"], "@/*": ["src/*"], diff --git a/jeecgboot-vue3/types/global.d.ts b/jeecgboot-vue3/types/global.d.ts index aef541edd..b0f1ea197 100644 --- a/jeecgboot-vue3/types/global.d.ts +++ b/jeecgboot-vue3/types/global.d.ts @@ -46,6 +46,11 @@ declare global { path?: EventTarget[]; } interface ImportMetaEnv extends ViteEnv { + readonly BASE_URL: string; + readonly MODE: string; + readonly DEV: boolean; + readonly PROD: boolean; + readonly SSR: boolean; __: unknown; } diff --git a/jeecgboot-vue3/vite.config.ts b/jeecgboot-vue3/vite.config.ts index 0e3932abe..e4fd7c4a6 100644 --- a/jeecgboot-vue3/vite.config.ts +++ b/jeecgboot-vue3/vite.config.ts @@ -51,6 +51,27 @@ export default async ({ command, mode }: ConfigEnv): Promise => { root, resolve: { alias: [ + // @logicflow/vue-node-registry 1.1.13 的 npm 包只发布了 src/,但 package.json + // main/module 指向 lib/、es/(不存在)。vite 6 esbuild 宽松能找到 src,rolldown 严格直接报错。 + // 暂时直接把 import 重定向到 src/index.ts。 + { + find: /^@logicflow\/vue-node-registry$/, + replacement: pathResolve('node_modules/@logicflow/vue-node-registry/src/index.ts'), + }, + // 把 @rys-fe/vite-plugin-theme 的客户端运行时重定向到项目内置版本(vite 8 适配) + // 用 RegExp 精确匹配,避免被父级别名误吞;不写后缀让 vite 自动用 resolve.extensions 补全 + { + find: /^@rys-fe\/vite-plugin-theme\/es\/client$/, + replacement: pathResolve('build/vite/plugin/theme-plugin/client/client'), + }, + { + find: /^@rys-fe\/vite-plugin-theme\/es\/colorUtils$/, + replacement: pathResolve('build/vite/plugin/theme-plugin/client/colorUtils'), + }, + { + find: /^@rys-fe\/vite-plugin-theme$/, + replacement: pathResolve('build/vite/plugin/theme-plugin/index'), + }, { find: 'vue-i18n', replacement: 'vue-i18n/dist/vue-i18n.cjs.js',