diff --git a/docs/.vitepress/theme/components/demo-block.vue b/docs/.vitepress/theme/components/demo-block.vue index 4c251545..f2d63d0e 100644 --- a/docs/.vitepress/theme/components/demo-block.vue +++ b/docs/.vitepress/theme/components/demo-block.vue @@ -162,7 +162,7 @@ } &:hover { - color: #409eff; + color: var(--el-color-primary,#409eff);; background-color: #f9fafc; } diff --git a/eslint.config.js b/eslint.config.js index ce9e0d31..2b5b12ca 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,7 +16,7 @@ export default defineConfig([ '*/**/public/**/*', '*/**/types/**/*', '*/**/*.config.ts', - './tepm/**/*', + './temp/**/*', 'vite-env.d.ts', ]), ...eslintConfig(path.join(path.dirname(fileURLToPath(import.meta.url)), 'tsconfig.json')), diff --git a/packages/design/src/Alert.vue b/packages/design/src/Alert.vue new file mode 100644 index 00000000..0dbac9a3 --- /dev/null +++ b/packages/design/src/Alert.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/design/src/Card.vue b/packages/design/src/Card.vue index 27329dc7..dffb1fb3 100644 --- a/packages/design/src/Card.vue +++ b/packages/design/src/Card.vue @@ -1,5 +1,5 @@ diff --git a/packages/form/src/containers/Container.vue b/packages/form/src/containers/Container.vue index 4c3f6cb8..c04dce7b 100644 --- a/packages/form/src/containers/Container.vue +++ b/packages/form/src/containers/Container.vue @@ -35,6 +35,9 @@ :label-title="config.labelTitle" :text="text" > + + {{ config.titleExtra }} @@ -222,17 +225,23 @@ -
- {{ - expand ? '收起配置' : '展开更多配置' - }} +
+ {{ expand ? '收起配置' : '展开更多配置' }}
diff --git a/packages/form/src/fields/Text.vue b/packages/form/src/fields/Text.vue index 4fa3cc83..d709e903 100644 --- a/packages/form/src/fields/Text.vue +++ b/packages/form/src/fields/Text.vue @@ -1,6 +1,31 @@ - +
输入内容前后有空格,是否移除空格?
@@ -80,28 +106,26 @@ watch( ); const appendConfig = computed(() => { - if (typeof props.config.append === 'string') { + const { append } = props.config; + + // 字符串形态:纯文本 append。 + if (typeof append === 'string') { return { type: 'text', - text: props.config.append, + text: append, handler: undefined, }; } - if (typeof props.config.append === 'object') { - if (typeof props.config.append?.handler === 'function') { - return { - type: 'button', - text: props.config.append.text, - handler: props.config.append.handler, - }; - } - if (props.config.append) { - if (props.config.append.value === 0) { - return false; - } - return props.config.append; - } + // 对象形态:按 schema 定义,`type` 可为 `'button' | 'icon'`,`handler` 可选。 + // + // 旧逻辑里只要带 handler 就强制覆盖为 `type: 'button'`,导致 schema 中显式传入的 + // `type: 'icon'` 在到达模板前被抹掉(参见 `@editor/utils/props.ts` 中 ID 字段 + // 的复制按钮配置)。这里直接返回原对象,模板按 `type` 分支渲染 button / icon / text。 + if (typeof append === 'object' && append) { + // `value === 0` 沿用旧约定:作为「隐藏 append」的开关。 + if (append.value === 0) return false; + return append; } return false; diff --git a/packages/form/src/plugin.ts b/packages/form/src/plugin.ts index 4a121745..085d5100 100644 --- a/packages/form/src/plugin.ts +++ b/packages/form/src/plugin.ts @@ -50,11 +50,13 @@ import { setConfig } from './utils/config'; import { registerTypeMatchRules, type TypeMatchValidator } from './utils/typeMatch'; import Form from './Form.vue'; import FormDialog from './FormDialog.vue'; +import FormDrawer from './FormDrawer.vue'; import './theme/index.scss'; // #region FormInstallOptions export interface FormInstallOptions { + flat?: boolean; /** 自定义字段 type 的 typeMatch 校验规则,可覆盖内置规则或扩展业务字段 */ typeMatchRules?: Record; [key: string]: any; @@ -76,6 +78,7 @@ export default { app.component('m-form', Form); app.component('m-form-dialog', FormDialog); + app.component('m-form-drawer', FormDrawer); app.component('m-form-container', Container); app.component('m-form-fieldset', Fieldset); app.component('m-form-group-list', TableGroupList); diff --git a/packages/form/src/theme/container.scss b/packages/form/src/theme/container.scss index 0f185270..332f7897 100644 --- a/packages/form/src/theme/container.scss +++ b/packages/form/src/theme/container.scss @@ -22,4 +22,7 @@ background: #f7dadd; } } + .m-form-title-extra { + margin-left: 8px; + } } diff --git a/packages/form/src/theme/fieldset.scss b/packages/form/src/theme/fieldset.scss index 3bd8390b..8f46940c 100644 --- a/packages/form/src/theme/fieldset.scss +++ b/packages/form/src/theme/fieldset.scss @@ -1,3 +1,54 @@ +.m-container-fieldset { + &.no-border-fieldset { + &.fieldset-in-row { + > fieldset.m-fieldset { + legend { + left: 0; + } + } + } + > fieldset.m-fieldset { + border: 0; + legend { + position: relative; + left: -5px; + font-size: 16px; + font-weight: 500; + line-height: 24px; + padding: 0; + } + } + } + &.fieldset-in-card-fit { + border-bottom: 1px solid #e6e6e6; + > fieldset.m-fieldset { + padding: 16px 0 16px 0 !important; + border: 0; + margin-top: 0; + margin-bottom: 0; + legend { + position: relative; + top: 10px; + background-color: transparent; + } + } + &.fieldset-in-card-fit-last { + padding-bottom: 0 !important; + border-bottom: 0; + } + &.fieldset-in-card-fit-first { + padding-top: 0 !important; + } + &:last-child { + padding-bottom: 0 !important; + border-bottom: 0; + } + &:first { + padding-top: 0 !important; + } + } +} + fieldset.m-fieldset { position: relative; border: 1px solid rgb(229, 229, 229); diff --git a/packages/form/src/theme/form.scss b/packages/form/src/theme/form.scss index 365357d4..3638b449 100644 --- a/packages/form/src/theme/form.scss +++ b/packages/form/src/theme/form.scss @@ -53,3 +53,9 @@ margin-bottom: var(--td-comp-margin-xxl); } } + +.m-form-container { + .m-form-container-expand { + text-align: center; + } +} diff --git a/packages/form/src/theme/group-list.scss b/packages/form/src/theme/group-list.scss index b8c32f23..778ea5ba 100644 --- a/packages/form/src/theme/group-list.scss +++ b/packages/form/src/theme/group-list.scss @@ -1,4 +1,8 @@ .m-fields-group-list { + .m-fields-group-list-item.tmagic-design-card--flat:last-child { + border: 0; + } + .el-button--text { padding: 0; margin-bottom: 7px; @@ -19,14 +23,11 @@ &:last-of-type { border-bottom: 0; } - } - .tmagic-design-card { - .el-card__header { - padding: 5px 20px; - } - .t-card__header { - padding: 5px 0; + .m-fields-group-list-item-header { + display: flex; + align-items: center; + gap: 8px; } } @@ -34,5 +35,6 @@ display: flex; justify-content: space-between; margin-top: 10px; + margin-bottom: 16px; } } diff --git a/packages/form/src/theme/index.scss b/packages/form/src/theme/index.scss index 3e87b8d1..3bcd54c1 100644 --- a/packages/form/src/theme/index.scss +++ b/packages/form/src/theme/index.scss @@ -13,3 +13,4 @@ @use "./tabs.scss"; @use "./number-range.scss"; @use "./form-box.scss"; +@use "./radio-group.scss"; diff --git a/packages/form/src/theme/link.scss b/packages/form/src/theme/link.scss index a00d7441..345fe255 100644 --- a/packages/form/src/theme/link.scss +++ b/packages/form/src/theme/link.scss @@ -1,3 +1,20 @@ div.m-fields-link { width: fit-content; } +a.magic-admin-link { + width: calc(100%); + box-sizing: border-box; + border-radius: 4px; + border: 1px solid #e6e6e6; + background-color: #fafafa; + padding: 0 8px; + height: 28px; + display: inline-flex; + gap: 4px; + align-items: center; + text-decoration: none; + + i { + margin-right: 4px; + } +} diff --git a/packages/form/src/theme/panel.scss b/packages/form/src/theme/panel.scss index d83cd698..56daebe5 100644 --- a/packages/form/src/theme/panel.scss +++ b/packages/form/src/theme/panel.scss @@ -4,7 +4,7 @@ background: #f2f6fc; } a { - color: #409eff; + color: var(--el-color-primary, #409eff); } } diff --git a/packages/form/src/theme/radio-group.scss b/packages/form/src/theme/radio-group.scss new file mode 100644 index 00000000..457a9c8d --- /dev/null +++ b/packages/form/src/theme/radio-group.scss @@ -0,0 +1,23 @@ +// 单个 radio option 的内容容器:横向排列 icon + 文本,垂直居中对齐。 +// 这样多行文本(`text: '内联布局\n inline'`)能跟左侧 icon 在视觉中线对齐, +// 不会出现 icon 顶到第一行的错位。 +.m-fields-radio-group__option { + display: flex; + align-items: center; +} + +.m-fields-radio-group__text { + // `inline-block` 关键:inline span 的 line-height 会被父级 inline formatting + // context(如 `.el-radio-button__inner` 的 strut)覆盖,外观高度量出来会比 + // `line-height` 大;显式声明 `inline-block` 让 span 有独立的格式化上下文, + // 自身 line-height 才真正生效。 + display: inline-block; + vertical-align: middle; + // `pre-line` 让 `\n` 渲染为换行,同时保留自动折行。 + white-space: pre-line; + line-height: 20px; +} + +.m-fields-radio-group__icon_with_text { + margin-right: 8px; +} diff --git a/packages/form/src/theme/tabs.scss b/packages/form/src/theme/tabs.scss index 6805908b..a9fba108 100644 --- a/packages/form/src/theme/tabs.scss +++ b/packages/form/src/theme/tabs.scss @@ -5,8 +5,8 @@ margin-right: auto; min-width: 50px; outline: none; - border-color: #409eff; - color: #409eff; + border-color: var(--el-color-primary, #409eff); + color: var(--el-color-primary, #409eff); width: 10px; &::before { content: "添加"; diff --git a/packages/form/src/theme/text.scss b/packages/form/src/theme/text.scss index 6c5ba72a..6bd5510f 100644 --- a/packages/form/src/theme/text.scss +++ b/packages/form/src/theme/text.scss @@ -2,6 +2,30 @@ display: flex; align-items: center; width: 100%; + + // 纯文本(非输入框)形态:保留 prepend / append 槽位的横向布局。 + // 配合 `TextConfig.static = true` 使用,常见于 ID 之类「只读 + 操作 icon」场景。 + &--static { + gap: 6px; + min-height: 32px; + + .m-fields-text__prepend { + color: var(--el-text-color-secondary, #909399); + } + + .m-fields-text__value { + color: var(--el-text-color-regular, #606266); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .m-fields-text__icon { + width: 16px; + height: 16px; + cursor: pointer; + } + } } .m-form-validate__warning { @@ -29,6 +53,20 @@ } } +.m-container-text { + &.m-fields-title-container { + .tmagic-design-form-item { + .el-form-item__label { + color: #0f1113; + font-weight: 500; + font-size: 16px; + } + line-height: 24px; + margin-bottom: 16px; + } + } +} + .tmagic-form-text-popper[data-popper-placement^="top"] > .tmagic-form-text-popper-arrow { bottom: -4px; diff --git a/packages/form/src/theme/themes/magic-admin/index.scss b/packages/form/src/theme/themes/magic-admin/index.scss new file mode 100644 index 00000000..c75982a9 --- /dev/null +++ b/packages/form/src/theme/themes/magic-admin/index.scss @@ -0,0 +1,95 @@ +// magic-admin 主题:所有规则均以 `.m-form.m-form--magic-admin` 为前缀, +// 仅在 `` 时生效,避免污染默认样式。 +@use "../../index.scss"; + +.m-form.m-form--magic-admin { + // 精确匹配"只挂 `m-form-container` + `m-container-fieldset` 这两个 class"的元素: + // `.a.b` 只能保证"包含"这两个 class,多余的 class 也会命中; + // 用 `[class="..."]` 做全量字符串等值匹配即可排除第三个 class 的情况。 + // 由于 Vue/框架不保证 class 拼接顺序,两种顺序都列出以覆盖到位。 + [class="m-form-container m-container-fieldset"], + [class="m-container-fieldset m-form-container"] { + margin-top: 15px; + } + + .m-container-tab:not(.magic-right-panel-tabs-top):not( + .magic-form-dynamic-tab + ) { + > .tmagic-design-tabs:not(.el-tabs--border-card) { + > .el-tabs__content { + background-color: #fff; + padding: 16px 16px 0 16px; + border-radius: 4px; + } + } + } + + .m-container-tab.magic-form-dynamic-tab { + } + + .m-form-tip { + color: rgba(0, 0, 0, 0.55); + } + + .tmagic-design-form-item { + &.show-after-diff { + background: #d4edbc; + } + + &.show-before-diff { + background: #fad2cf; + } + } + .m-fields-radio-group__text { + // `pre-line` 让 `\n` 渲染为换行,同时保留自动折行;线高与默认按钮组高度对齐。 + white-space: pre-line; + line-height: 20px; + } + .el-form-item__content { + .tmagic-design-radio-group { + .tmagic-design-radio-button { + height: 32px; + .el-radio-button__inner { + height: 32px; + display: flex; + padding-top: 0; + padding-bottom: 0; + align-items: center; + } + } + } + } + .m-fields-data-source-field-select .tmagic-design-button { + margin-left: 8px; + span { + font-size: 16px; + } + } + + .m-fields-group-list-item.tmagic-design-card--flat { + .el-card__body { + .m-fields-table-wrap { + .m-fields-table { + .el-table__header-wrapper { + thead { + height: 48px; + } + } + } + } + } + .m-fields-group-list-item-header { + position: relative; + .delete-button { + position: absolute; + right: 0; + color: #0f1113; + } + } + } + .m-form-container { + .m-form-container-expand { + text-align: left; + } + } +} diff --git a/packages/form/src/utils/config.ts b/packages/form/src/utils/config.ts index 56cf552e..64aba03f 100644 --- a/packages/form/src/utils/config.ts +++ b/packages/form/src/utils/config.ts @@ -17,11 +17,17 @@ */ import type { Component } from 'vue'; +import { ref } from 'vue'; let $MAGIC_FORM = {} as any; +// 用 ref 持有 flat 全局开关:const 引用本身不可变,符合 `import/no-mutable-exports`; +// 通过 `.value` 改值仍保持模块级响应式语义,与 editor/plugin.ts、design/index.ts 里 +// 的同名变量写法对齐。 +const isGlobalFlat = ref(false); const setConfig = (option: any): void => { $MAGIC_FORM = option; + isGlobalFlat.value = option.flat ?? false; }; const getConfig = (key: string): T => $MAGIC_FORM[key]; @@ -39,4 +45,4 @@ const getField = (tagName: string): Component | undefined => fieldRegistry.get(t const deleteField = (tagName: string): boolean => fieldRegistry.delete(tagName); -export { deleteField, getConfig, getField, registerField, setConfig }; +export { deleteField, getConfig, getField, isGlobalFlat, registerField, setConfig }; diff --git a/packages/form/tests/unit/Form.extra.spec.ts b/packages/form/tests/unit/Form.extra.spec.ts index 61f178c7..3e7d681b 100644 --- a/packages/form/tests/unit/Form.extra.spec.ts +++ b/packages/form/tests/unit/Form.extra.spec.ts @@ -90,6 +90,22 @@ describe('Form.vue —— formState getter 行为', () => { expect(fs2.parentValues).toEqual({ x: 2 }); }); + test('formState.popperClass 在传入 theme 时自动追加 m-theme 修饰类', async () => { + const wrapper = mountForm({ popperClass: 'pop-x', theme: 'magic-admin' }); + await nextTick(); + + const fs: any = wrapper.vm.formState; + expect(fs.popperClass).toBe('pop-x m-theme--magic-admin'); + + // 仅 theme 没有用户 popperClass 时不带前导空格 + await wrapper.setProps({ popperClass: undefined }); + expect(wrapper.vm.formState.popperClass).toBe('m-theme--magic-admin'); + + // 取消 theme 后回退为原始 popperClass + await wrapper.setProps({ theme: undefined, popperClass: 'pop-x' }); + expect(wrapper.vm.formState.popperClass).toBe('pop-x'); + }); + test('values / lastValuesProcessed 在 formState 上自动解包为 ref 当前值', async () => { const wrapper = mountForm({ isCompare: true, diff --git a/packages/stage/src/moveable-able.css b/packages/stage/src/moveable-able.css index 03d2545f..46b0031a 100644 --- a/packages/stage/src/moveable-able.css +++ b/packages/stage/src/moveable-able.css @@ -1,7 +1,7 @@ .moveable-button { width: 20px; height: 20px; - background: #4af; + background: var(--el-color-primary,#4af); border-radius: 4px; appearance: none; border: 0; diff --git a/playground/src/App.vue b/playground/src/App.vue index 8a25bb00..0a8df79a 100644 --- a/playground/src/App.vue +++ b/playground/src/App.vue @@ -12,6 +12,7 @@ export default { html, body, #app { + --el-color-primary: #0054e1; padding: 0; margin: 0; width: 100%; diff --git a/playground/src/configs/formDsl.ts b/playground/src/configs/formDsl.ts index c3284621..270718be 100644 --- a/playground/src/configs/formDsl.ts +++ b/playground/src/configs/formDsl.ts @@ -8,6 +8,16 @@ export default createForm([ extra: (vm: any, { model }: any) => `${model.text}extra`, rules: [{ required: true, message: '请输入活动名称', trigger: 'blur' }], }, + // `img-upload` 是 playground 自定义业务字段,未在 `FormItemConfig` 联合里注册; + // `checkStrictly` 又是 `DataSourceFieldSelectConfig` 的专属字段,会把 TS 收窄到该 + // 分支再报「type 不匹配」。这里用 `as any` 放过,正式工程里应通过 `createForm` + // 的泛型把自定义类型并入。 + { + type: 'img-upload', + checkStrictly: false, + name: 'backgroundImage', + text: '背景图', + } as any, { type: 'checkbox', text: 'checkbox', diff --git a/playground/src/main.ts b/playground/src/main.ts index f1e52973..ab7a0ec7 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -28,7 +28,8 @@ import editorPlugin from '@tmagic/editor'; import App from './App.vue'; import router from './route'; -import '@tmagic/editor/dist/style.css'; +// import '@tmagic/editor/dist/style.css'; +import '@tmagic/editor/dist/themes/magic-admin.css'; // @ts-ignore globalThis.MonacoEnvironment = { @@ -64,6 +65,6 @@ if (adapter === 'tdesign-vue-next') { adapterModule.then((module: any) => { const app = createApp(App); app.use(router); - app.use(editorPlugin, module.default); + app.use(editorPlugin, { ...module.default, flat: true }); app.mount('#app'); }); diff --git a/playground/src/pages/Editor.vue b/playground/src/pages/Editor.vue index fa584b16..388d6ce8 100644 --- a/playground/src/pages/Editor.vue +++ b/playground/src/pages/Editor.vue @@ -11,6 +11,7 @@ :datasource-event-method-list="datasourceEventMethodList" :datasource-configs="datasourceConfigs" :datasource-values="datasourceValues" + theme="magic-admin" :component-group-list="componentGroupList" :datasource-list="datasourceList" :default-selected="defaultSelected" @@ -62,6 +63,7 @@ import { TMagicDialog, TMagicEditor, tMagicMessage, + uiService, } from '@tmagic/editor'; import DeviceGroup from '../components/DeviceGroup.vue'; @@ -75,6 +77,8 @@ import { useEditorRes } from './composables/use-editor-res'; const { VITE_RUNTIME_PATH } = import.meta.env; +uiService.set('propsPanelSize', 'default'); + const datasourceList: DatasourceTypeOption[] = []; const runtimeUrl = `${VITE_RUNTIME_PATH}/playground/index.html`; diff --git a/playground/vite.config.ts b/playground/vite.config.ts index 976603fa..4e724b36 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -40,6 +40,32 @@ export default defineConfig({ find: /^@tmagic\/editor\/dist\/style.css/, replacement: path.join(__dirname, '../packages/editor/src/theme/index.scss'), }, + // 开发态:把 `@tmagic/editor/dist/themes/.css` 直接指到对应 SCSS 源码, + // 这样调主题样式不用先跑 build。$1 来自 `find` 正则的捕获组。 + { + find: /^@tmagic\/editor\/dist\/themes\/(.+)\.css$/, + replacement: path.join(__dirname, '../packages/editor/src/theme/themes/$1/index.scss'), + }, + { + find: /^@tmagic\/form\/dist\/themes\/(.+)\.css$/, + replacement: path.join(__dirname, '../packages/form/src/theme/themes/$1/index.scss'), + }, + // 主题 SCSS 在内部互相 @use 时(例如 editor 的主题里 @use 了 design / form 的主题), + // 写的是 `@tmagic//src/theme/themes//index.scss`。这些 SCSS 子路径必须 + // 在下方通用的 `^@tmagic/` 规则(指向 `src/index.ts`)之前命中,否则会被 + // 错误改写成 `.../src/index.ts/src/theme/themes/...` 触发 ENOTDIR。 + { + find: /^@tmagic\/editor\/src\/theme\/themes\/(.+)\/index\.scss$/, + replacement: path.join(__dirname, '../packages/editor/src/theme/themes/$1/index.scss'), + }, + { + find: /^@tmagic\/form\/src\/theme\/themes\/(.+)\/index\.scss$/, + replacement: path.join(__dirname, '../packages/form/src/theme/themes/$1/index.scss'), + }, + { + find: /^@tmagic\/design\/src\/theme\/themes\/(.+)\/index\.scss$/, + replacement: path.join(__dirname, '../packages/design/src/theme/themes/$1/index.scss'), + }, { find: /^@tmagic\/form\/src\/theme\/index.scss/, replacement: path.join(__dirname, '../packages/form/src/theme/index.scss'), diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3a4a5ff0..b9f465fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,4 +14,4 @@ catalog: overrides: element-plus: ^2.14.3 - vue: ^3.5.40 + vue: ^3.5.40 \ No newline at end of file diff --git a/scripts/build.mjs b/scripts/build.mjs index 14f1a3dc..482a5d28 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,6 +7,7 @@ import { build as buildVite } from 'vite'; import vue from '@vitejs/plugin-vue'; import minimist from 'minimist'; import rimraf from 'rimraf'; +import * as sass from 'sass-embedded'; const args = minimist(process.argv.slice(2)); @@ -24,6 +25,7 @@ if (args.package) { build({ packageName: args.package, format: 'es', pkg, packagesDir }); build({ packageName: args.package, format: 'umd', pkg, packagesDir }); + buildThemes({ packageName: args.package, packagesDir }); } } else { const packages = getPackageNames(packagesDir); @@ -35,6 +37,7 @@ if (args.package) { build({ packageName, format: 'es', pkg, packagesDir }); build({ packageName, format: 'umd', pkg, packagesDir }); + buildThemes({ packageName, packagesDir }); } for (const packageName of runtimeHelpers) { @@ -133,6 +136,47 @@ async function build({ packageName, format, pkg, packagesDir }) { }); } +/** + * 扫描 `/src/theme/themes//index.scss`, + * 为每个主题单独编译输出 `/dist/themes/.css`。 + * + * 主题样式独立于主样式包(dist/style.css),消费者通过 + * `import '@tmagic//dist/themes/.css'` 按需引入, + * 并在 `` / `` + * 上挂载对应的 `m-editor--` / `m-form--` 修饰类后生效。 + */ +function buildThemes({ packageName, packagesDir }) { + const themesDir = path.resolve(packagesDir, `./${packageName}/src/theme/themes`); + if (!fs.existsSync(themesDir)) return; + + const themes = fs.readdirSync(themesDir).filter((name) => { + const themePath = path.resolve(themesDir, name); + return fs.statSync(themePath).isDirectory() && fs.existsSync(path.resolve(themePath, 'index.scss')); + }); + if (themes.length === 0) return; + + const outputDir = path.resolve(packagesDir, `./${packageName}/dist/themes`); + fs.mkdirSync(outputDir, { recursive: true }); + + // 把 `/node_modules` 也加到 loadPaths,使主题 SCSS 内 + // `@use "@tmagic//src/.../index.scss"` 能通过 pnpm 在 node_modules + // 下的 symlink 解析到对应的源码包。 + const nodeModulesDir = path.resolve(packagesDir, `./${packageName}/node_modules`); + const loadPaths = [path.resolve(packagesDir, `./${packageName}/src`)]; + if (fs.existsSync(nodeModulesDir)) loadPaths.push(nodeModulesDir); + + for (const theme of themes) { + const input = path.resolve(themesDir, theme, 'index.scss'); + const result = sass.compile(input, { + style: 'expanded', + sourceMap: false, + loadPaths: [path.resolve(themesDir, theme), ...loadPaths], + }); + fs.writeFileSync(path.resolve(outputDir, `${theme}.css`), result.css); + console.log(`[themes] ${packageName}: ${theme}.css`); + } +} + function getPackageNames(packagesDir) { return fs.readdirSync(packagesDir).filter((p) => { const pkgRoot = path.resolve(packagesDir, p);