优化风格
This commit is contained in:
10
src/App.tsx
10
src/App.tsx
@@ -498,12 +498,13 @@ export default function App() {
|
||||
|
||||
const mappedRows = useMemo(
|
||||
() =>
|
||||
activeRows.map((row) =>
|
||||
activeRows.map((row, rowIndex) =>
|
||||
applyRowVariableMapping(
|
||||
row,
|
||||
variableColumnMapping,
|
||||
templateVariables,
|
||||
workingTemplate.variableDefaults
|
||||
workingTemplate.variableDefaults,
|
||||
rowIndex
|
||||
)
|
||||
),
|
||||
[activeRows, variableColumnMapping, templateVariables, workingTemplate.variableDefaults]
|
||||
@@ -530,12 +531,13 @@ export default function App() {
|
||||
|
||||
const appliedMappedRows = useMemo(() => {
|
||||
if (!appliedExportData) return [];
|
||||
return appliedExportData.rows.map((row) =>
|
||||
return appliedExportData.rows.map((row, rowIndex) =>
|
||||
applyRowVariableMapping(
|
||||
row,
|
||||
appliedExportData.mapping,
|
||||
templateVariables,
|
||||
workingTemplate.variableDefaults
|
||||
workingTemplate.variableDefaults,
|
||||
rowIndex
|
||||
)
|
||||
);
|
||||
}, [appliedExportData, templateVariables, workingTemplate.variableDefaults]);
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as XLSX from 'xlsx';
|
||||
import { ImportData, RecordRow } from '../types';
|
||||
import { FileSpreadsheet, Upload, Download, Trash2 } from 'lucide-react';
|
||||
import { useAppDialog } from './AppDialog';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
|
||||
export function downloadDataTemplate(templateName: string, templateVariables: string[]) {
|
||||
const columns = templateVariables.length > 0 ? templateVariables : ['数据'];
|
||||
@@ -331,7 +332,7 @@ export const DataImporter: React.FC<DataImporterProps> = ({
|
||||
>
|
||||
工作表
|
||||
</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={importData.currentSheet}
|
||||
disabled={sheetLoading}
|
||||
onChange={(e) => void handleSheetChange(e.target.value)}
|
||||
@@ -342,7 +343,7 @@ export const DataImporter: React.FC<DataImporterProps> = ({
|
||||
{sheet}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
{sheetLoading && (
|
||||
<p className={`${isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'}`}>
|
||||
切换中…
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Filter } from 'lucide-react';
|
||||
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types';
|
||||
import { buildPrintablePages } from '../utils';
|
||||
import { PageInput } from './PreviewGrid';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
|
||||
interface DataRangeFieldsProps {
|
||||
variant?: 'ps' | 'mobile';
|
||||
@@ -36,7 +37,7 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
|
||||
<div>
|
||||
{isMobile && <label className="mobile-field-label">范围</label>}
|
||||
{/* {!isMobile && <label className="ps-field-label">范围</label>} */}
|
||||
<select
|
||||
<FieldSelect
|
||||
value={exportRange.mode}
|
||||
disabled={disabled}
|
||||
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
|
||||
@@ -46,7 +47,7 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
|
||||
<option value="odd">仅限奇数序号的行</option>
|
||||
<option value="even">仅限偶数序号的行</option>
|
||||
<option value="custom">自定义行序号</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{exportRange.mode === 'custom' && (
|
||||
<div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { VariableMappingPanel } from './VariableMappingPanel';
|
||||
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
||||
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
||||
import { DataRangeFields } from './DataRangeFields';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
|
||||
interface ExportSidebarProps {
|
||||
importData: ImportData | null;
|
||||
@@ -220,7 +221,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
>
|
||||
<div>
|
||||
<label className="ps-field-label">线型</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={cutLine.style}
|
||||
onChange={(e) =>
|
||||
onChangeCutLine({
|
||||
@@ -232,7 +233,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
>
|
||||
<option value="dashed">虚线</option>
|
||||
<option value="solid">实线</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">线粗细 (mm)</label>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
RecordRow,
|
||||
} from '../types';
|
||||
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
|
||||
interface MobileExportLayoutPanelProps {
|
||||
template: LabelTemplate;
|
||||
@@ -59,7 +60,7 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
|
||||
>
|
||||
<div>
|
||||
<label className="ps-field-label">线型</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={cutLine.style}
|
||||
onChange={(e) =>
|
||||
onChangeCutLine({
|
||||
@@ -71,7 +72,7 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
|
||||
>
|
||||
<option value="dashed">虚线</option>
|
||||
<option value="solid">实线</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">线粗细 (mm)</label>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import { useAppDialog } from './AppDialog';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { FileDown, RefreshCcw, X } from 'lucide-react';
|
||||
|
||||
interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
@@ -316,7 +317,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||
<h4 className={sectionTitleClass}>纸张</h4>
|
||||
<div>
|
||||
<label className={labelClass}>纸张类型</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={draftPaper.type}
|
||||
onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)}
|
||||
className={selectClass}
|
||||
@@ -325,7 +326,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||
<option value="A5">A5 (148×210 mm)</option>
|
||||
<option value="A6">A6 (105×148 mm)</option>
|
||||
<option value="custom">自定义尺寸</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
|
||||
{draftPaper.type === 'custom' && (
|
||||
@@ -499,14 +500,14 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>填充顺序</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={draftPaper.flow}
|
||||
onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="left-right-top-bottom">左右 → 上下(行优先)</option>
|
||||
<option value="top-bottom-left-right">上下 → 左右(列优先)</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type AlignMode,
|
||||
} from '../elementAlign';
|
||||
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import { useAppDialog } from './AppDialog';
|
||||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||
@@ -682,7 +683,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
<div className="text-[10px] font-bold text-[#31a8ff]">显示控制</div>
|
||||
<div>
|
||||
<label className="ps-field-label">显示模式</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={isConditionalVisibility(selectedElem) ? 'conditional' : 'always'}
|
||||
onChange={(e) => {
|
||||
const conditional = e.target.value === 'conditional';
|
||||
@@ -710,7 +711,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
>
|
||||
<option value="always">始终显示</option>
|
||||
<option value="conditional">按条件显示</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{isConditionalVisibility(selectedElem) && (() => {
|
||||
const rules = resolveVisibilityRules(selectedElem);
|
||||
@@ -962,7 +963,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ps-field-label">显示格式</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={selectedElem.textFormat || 'text'}
|
||||
onChange={(e) =>
|
||||
handleElemChange({
|
||||
@@ -980,7 +981,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
<option value="number">数值</option>
|
||||
<option value="percent">百分比</option>
|
||||
<option value="currency">货币 (¥)</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{(selectedElem.textFormat === 'number' ||
|
||||
selectedElem.textFormat === 'percent' ||
|
||||
@@ -1004,7 +1005,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="ps-field-label">数值显示部分</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={selectedElem.numberPartDisplay ?? 'full'}
|
||||
onChange={(e) =>
|
||||
handleElemChange({
|
||||
@@ -1020,7 +1021,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
<option value="full">完整数值</option>
|
||||
<option value="integer">仅整数部分(如 19.9 → 19)</option>
|
||||
<option value="fraction">仅小数部分(如 19.9 → .9)</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||
可将价格拆成两个文本元素分别设置字号,如「19」与「.9」
|
||||
</p>
|
||||
@@ -1045,7 +1046,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">字体库</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={selectedElem.fontFamily}
|
||||
onChange={(e) =>
|
||||
handleElemChange({
|
||||
@@ -1058,7 +1059,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
<option value="sans">黑体 (Sans)</option>
|
||||
<option value="serif">宋体 (Serif)</option>
|
||||
<option value="mono">等宽体 (Mono)</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1303,7 +1304,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</h5>
|
||||
<div>
|
||||
<label className="ps-field-label">条码码制</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={selectedElem.barcodeFormat}
|
||||
onChange={(e) =>
|
||||
handleElemChange({
|
||||
@@ -1317,7 +1318,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
<option value="CODE39">CODE 39 (数字和字符)</option>
|
||||
<option value="EAN13">EAN 13 (纯商品数字 13位)</option>
|
||||
<option value="ITF">ITF (交插双五码)</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer mt-1">
|
||||
@@ -1341,7 +1342,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</h5>
|
||||
<div>
|
||||
<label className="ps-field-label">适应方式</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={selectedElem.imageFit ?? 'contain'}
|
||||
onChange={(e) =>
|
||||
handleElemChange({
|
||||
@@ -1354,7 +1355,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
<option value="contain">包含 (contain)</option>
|
||||
<option value="cover">覆盖 (cover)</option>
|
||||
<option value="fill">拉伸 (fill)</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
237
src/components/PsSelect.tsx
Normal file
237
src/components/PsSelect.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const PANEL_GAP = 4;
|
||||
const VIEWPORT_PAD = 8;
|
||||
|
||||
type ParsedOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function optionLabel(children: React.ReactNode): string {
|
||||
if (children === null || children === undefined) return '';
|
||||
if (typeof children === 'string' || typeof children === 'number') return String(children);
|
||||
return React.Children.toArray(children).join('');
|
||||
}
|
||||
|
||||
function parseSelectOptions(children: React.ReactNode): ParsedOption[] {
|
||||
const out: ParsedOption[] = [];
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement(child) || child.type !== 'option') return;
|
||||
const props = child.props as {
|
||||
value?: string | number;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
out.push({
|
||||
value: String(props.value ?? ''),
|
||||
label: optionLabel(props.children),
|
||||
disabled: props.disabled,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function useSelectPanelPosition(
|
||||
open: boolean,
|
||||
anchorRef: React.RefObject<HTMLElement | null>,
|
||||
panelRef: React.RefObject<HTMLElement | null>
|
||||
) {
|
||||
const [coords, setCoords] = useState<{ top: number; left: number; width: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setCoords(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const anchor = anchorRef.current;
|
||||
const panel = panelRef.current;
|
||||
if (!anchor || !panel) return;
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const panelHeight = panel.offsetHeight;
|
||||
let top = rect.bottom + PANEL_GAP;
|
||||
if (top + panelHeight > window.innerHeight - VIEWPORT_PAD) {
|
||||
top = rect.top - panelHeight - PANEL_GAP;
|
||||
}
|
||||
top = Math.max(
|
||||
VIEWPORT_PAD,
|
||||
Math.min(top, window.innerHeight - panelHeight - VIEWPORT_PAD)
|
||||
);
|
||||
|
||||
const width = rect.width;
|
||||
let left = rect.left;
|
||||
left = Math.max(VIEWPORT_PAD, Math.min(left, window.innerWidth - width - VIEWPORT_PAD));
|
||||
|
||||
setCoords({ top, left, width });
|
||||
};
|
||||
|
||||
update();
|
||||
const raf = requestAnimationFrame(update);
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('scroll', update, true);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('scroll', update, true);
|
||||
};
|
||||
}, [open, anchorRef, panelRef]);
|
||||
|
||||
return coords;
|
||||
}
|
||||
|
||||
export interface PsSelectProps
|
||||
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange' | 'value'> {
|
||||
value: string;
|
||||
onChange: React.ChangeEventHandler<HTMLSelectElement>;
|
||||
}
|
||||
|
||||
export const PsSelect: React.FC<PsSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
className = 'ps-field',
|
||||
disabled,
|
||||
id,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const options = useMemo(() => parseSelectOptions(children), [children]);
|
||||
const selected = options.find((opt) => opt.value === value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const coords = useSelectPanelPosition(open, anchorRef, panelRef);
|
||||
|
||||
const pick = (next: string) => {
|
||||
onChange({ target: { value: next } } as React.ChangeEvent<HTMLSelectElement>);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
{...rest}
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
className={`ps-select-trigger ${className}`.trim()}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
<span className="ps-select-trigger-label truncate">
|
||||
{selected?.label ?? (value ? value : '\u00A0')}
|
||||
</span>
|
||||
</button>
|
||||
{open &&
|
||||
typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="ps-select-panel"
|
||||
role="listbox"
|
||||
aria-labelledby={id}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords?.top ?? 0,
|
||||
left: coords?.left ?? 0,
|
||||
width: coords?.width,
|
||||
visibility: coords ? 'visible' : 'hidden',
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
{options.map((opt, index) => (
|
||||
<button
|
||||
key={`${opt.value}-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
disabled={opt.disabled}
|
||||
className={`ps-select-option${opt.value === value ? ' selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (opt.disabled) return;
|
||||
pick(opt.value);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** ps-field 时用 PsSelect,否则保留原生 select */
|
||||
export const FieldSelect: React.FC<React.SelectHTMLAttributes<HTMLSelectElement>> = (props) => {
|
||||
const { className = '', children, onChange, value, disabled, id, ...rest } = props;
|
||||
const usePsSelect = /\bps-field\b/.test(className);
|
||||
|
||||
if (usePsSelect && onChange) {
|
||||
return (
|
||||
<PsSelect
|
||||
className={className}
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</PsSelect>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { TemplateElement } from '../types';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
|
||||
interface ValueExtractFieldsProps {
|
||||
elem: TemplateElement;
|
||||
@@ -21,7 +22,7 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="ps-field-label">值提取</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={extractMode}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as 'none' | 'split' | 'prefix' | 'suffix';
|
||||
@@ -51,7 +52,7 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
|
||||
<option value="split">按分隔符</option>
|
||||
<option value="prefix">前 N 位</option>
|
||||
<option value="suffix">后 N 位</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{extractMode === 'split' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React from 'react';
|
||||
import { VARIABLE_MAPPING_USE_DEFAULT } from '../utils';
|
||||
import {
|
||||
VARIABLE_MAPPING_USE_DEFAULT,
|
||||
VARIABLE_MAPPING_USE_ROW_INDEX,
|
||||
VARIABLE_MAPPING_ROW_INDEX_LABEL,
|
||||
} from '../utils';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
|
||||
interface VariableMappingPanelProps {
|
||||
variables: string[];
|
||||
@@ -110,19 +115,22 @@ export const VariableMappingPanel: React.FC<VariableMappingPanelProps> = ({
|
||||
isMobile ? 'text-sm text-indigo-600' : isPs ? 'text-[#31a8ff]' : 'text-indigo-700'
|
||||
}`}
|
||||
>{`{${varName}}`}</span>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={mappedCol}
|
||||
onChange={(e) => onChangeMapping({ ...mapping, [varName]: e.target.value })}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">请选择列或默认值</option>
|
||||
<option value="">请选择列、数据序号或默认值</option>
|
||||
<option value={VARIABLE_MAPPING_USE_DEFAULT}>{useDefaultLabel}</option>
|
||||
<option value={VARIABLE_MAPPING_USE_ROW_INDEX}>
|
||||
{VARIABLE_MAPPING_ROW_INDEX_LABEL}
|
||||
</option>
|
||||
{columns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
{mappedCol === VARIABLE_MAPPING_USE_DEFAULT && !defaultVal && (
|
||||
<p
|
||||
className={
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
VISIBILITY_RULE_TARGET_LABELS,
|
||||
} from '../types';
|
||||
import { visibilityOperatorNeedsValue } from '../utils';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
|
||||
export interface VisibilityRuleDraft {
|
||||
target: VisibilityRuleTarget;
|
||||
@@ -32,7 +33,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="ps-field-label">条件对象</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={target}
|
||||
onChange={(e) => {
|
||||
const nextTarget = e.target.value as VisibilityRuleTarget;
|
||||
@@ -48,12 +49,12 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
||||
{VISIBILITY_RULE_TARGET_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{target === 'variable' ? (
|
||||
<div>
|
||||
<label className="ps-field-label">变量</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={rule.variable}
|
||||
onChange={(e) => onChange({ variable: e.target.value })}
|
||||
className="ps-field text-[11px] w-full mt-0.5"
|
||||
@@ -64,7 +65,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
{variableOptions.length === 0 && (
|
||||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||
请先在「变量管理」中添加变量,或改用「内容值」作为条件对象
|
||||
@@ -78,7 +79,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
||||
)}
|
||||
<div>
|
||||
<label className="ps-field-label">条件</label>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={rule.operator}
|
||||
onChange={(e) => {
|
||||
const operator = e.target.value as VisibilityOperator;
|
||||
@@ -94,7 +95,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
||||
{VISIBILITY_OPERATOR_LABELS[op]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
{visibilityOperatorNeedsValue(rule.operator) && (
|
||||
<div>
|
||||
|
||||
@@ -340,6 +340,89 @@ body {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.ps-field:is(select),
|
||||
.ps-select-trigger.ps-field,
|
||||
.ps-field.ps-select-trigger,
|
||||
.ps-panel-body select:not(.mobile-field),
|
||||
.ps-modal select {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
color-scheme: dark;
|
||||
background-color: var(--color-ps-input);
|
||||
padding-right: calc(0.625rem + 0.75rem + 0.625rem);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.625rem center;
|
||||
background-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ps-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ps-select-trigger-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ps-select-panel {
|
||||
box-sizing: border-box;
|
||||
background: var(--color-ps-input);
|
||||
border: 1px solid var(--color-ps-input-border);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
max-height: min(240px, 50vh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.ps-select-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.625rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-ps-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ps-select-option:hover:not(:disabled) {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.ps-select-option.selected {
|
||||
color: var(--color-ps-accent);
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.ps-select-option:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ps-field:is(select) option,
|
||||
.ps-panel-body select:not(.mobile-field) option,
|
||||
.ps-modal select option {
|
||||
background-color: var(--color-ps-input);
|
||||
color: var(--color-ps-text);
|
||||
}
|
||||
|
||||
.ps-field:is(select) option:checked,
|
||||
.ps-panel-body select:not(.mobile-field) option:checked,
|
||||
.ps-modal select option:checked {
|
||||
background-color: #2d2d2d;
|
||||
color: var(--color-ps-accent);
|
||||
}
|
||||
|
||||
.ps-field:focus {
|
||||
border-color: var(--color-ps-accent);
|
||||
outline: none;
|
||||
@@ -1093,6 +1176,15 @@ body {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
select.mobile-field {
|
||||
padding-right: calc(12px + 0.75rem + 12px);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-field:focus {
|
||||
outline: none;
|
||||
border-color: #818cf8;
|
||||
|
||||
15
src/utils.ts
15
src/utils.ts
@@ -22,6 +22,11 @@ const VARIABLE_MAPPING_KEY = 'label_var_column_mapping_v2';
|
||||
/** 变量映射:使用模板中配置的默认值(非数据列) */
|
||||
export const VARIABLE_MAPPING_USE_DEFAULT = '__use_default__';
|
||||
|
||||
/** 变量映射:使用当前数据行序号(1-based,与 {#INDEX} 一致) */
|
||||
export const VARIABLE_MAPPING_USE_ROW_INDEX = '__use_row_index__';
|
||||
|
||||
export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号';
|
||||
|
||||
export function toDataSourceMeta(data: ImportData): DataSourceMeta {
|
||||
return {
|
||||
fileName: data.fileName,
|
||||
@@ -111,19 +116,25 @@ export function isVariableMappingConfigured(
|
||||
const mapped = mapping[varName];
|
||||
if (!mapped) return false;
|
||||
if (mapped === VARIABLE_MAPPING_USE_DEFAULT) return true;
|
||||
if (mapped === VARIABLE_MAPPING_USE_ROW_INDEX) return true;
|
||||
return columns.includes(mapped);
|
||||
}
|
||||
|
||||
/** 按映射将数据行中的列值填入模板变量键;「使用默认值」时写入 variableDefaults */
|
||||
/** 按映射将数据行中的列值填入模板变量键;「使用默认值」/「数据序号」时写入对应值 */
|
||||
export function applyRowVariableMapping(
|
||||
row: RecordRow,
|
||||
mapping: Record<string, string>,
|
||||
variables: string[],
|
||||
variableDefaults?: Record<string, string> | null
|
||||
variableDefaults?: Record<string, string> | null,
|
||||
rowIndex: number = 0
|
||||
): RecordRow {
|
||||
const result = { ...row };
|
||||
for (const varName of variables) {
|
||||
const mapped = mapping[varName];
|
||||
if (mapped === VARIABLE_MAPPING_USE_ROW_INDEX) {
|
||||
result[varName] = String(rowIndex + 1);
|
||||
continue;
|
||||
}
|
||||
if (mapped === VARIABLE_MAPPING_USE_DEFAULT) {
|
||||
const def = variableDefaults?.[varName];
|
||||
if (def !== undefined && String(def).trim() !== '') {
|
||||
|
||||
Reference in New Issue
Block a user