优化风格

This commit is contained in:
iqudoo
2026-06-13 12:56:41 +08:00
parent 765d94b0b4
commit 8b09bcaa14
13 changed files with 400 additions and 42 deletions

View File

@@ -498,12 +498,13 @@ export default function App() {
const mappedRows = useMemo( const mappedRows = useMemo(
() => () =>
activeRows.map((row) => activeRows.map((row, rowIndex) =>
applyRowVariableMapping( applyRowVariableMapping(
row, row,
variableColumnMapping, variableColumnMapping,
templateVariables, templateVariables,
workingTemplate.variableDefaults workingTemplate.variableDefaults,
rowIndex
) )
), ),
[activeRows, variableColumnMapping, templateVariables, workingTemplate.variableDefaults] [activeRows, variableColumnMapping, templateVariables, workingTemplate.variableDefaults]
@@ -530,12 +531,13 @@ export default function App() {
const appliedMappedRows = useMemo(() => { const appliedMappedRows = useMemo(() => {
if (!appliedExportData) return []; if (!appliedExportData) return [];
return appliedExportData.rows.map((row) => return appliedExportData.rows.map((row, rowIndex) =>
applyRowVariableMapping( applyRowVariableMapping(
row, row,
appliedExportData.mapping, appliedExportData.mapping,
templateVariables, templateVariables,
workingTemplate.variableDefaults workingTemplate.variableDefaults,
rowIndex
) )
); );
}, [appliedExportData, templateVariables, workingTemplate.variableDefaults]); }, [appliedExportData, templateVariables, workingTemplate.variableDefaults]);

View File

@@ -3,6 +3,7 @@ import * as XLSX from 'xlsx';
import { ImportData, RecordRow } from '../types'; import { ImportData, RecordRow } from '../types';
import { FileSpreadsheet, Upload, Download, Trash2 } from 'lucide-react'; import { FileSpreadsheet, Upload, Download, Trash2 } from 'lucide-react';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { FieldSelect } from './PsSelect';
export function downloadDataTemplate(templateName: string, templateVariables: string[]) { export function downloadDataTemplate(templateName: string, templateVariables: string[]) {
const columns = templateVariables.length > 0 ? templateVariables : ['数据']; const columns = templateVariables.length > 0 ? templateVariables : ['数据'];
@@ -331,7 +332,7 @@ export const DataImporter: React.FC<DataImporterProps> = ({
> >
</label> </label>
<select <FieldSelect
value={importData.currentSheet} value={importData.currentSheet}
disabled={sheetLoading} disabled={sheetLoading}
onChange={(e) => void handleSheetChange(e.target.value)} onChange={(e) => void handleSheetChange(e.target.value)}
@@ -342,7 +343,7 @@ export const DataImporter: React.FC<DataImporterProps> = ({
{sheet} {sheet}
</option> </option>
))} ))}
</select> </FieldSelect>
{sheetLoading && ( {sheetLoading && (
<p className={`${isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'}`}> <p className={`${isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'}`}>

View File

@@ -3,6 +3,7 @@ import { Filter } from 'lucide-react';
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types'; import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types';
import { buildPrintablePages } from '../utils'; import { buildPrintablePages } from '../utils';
import { PageInput } from './PreviewGrid'; import { PageInput } from './PreviewGrid';
import { FieldSelect } from './PsSelect';
interface DataRangeFieldsProps { interface DataRangeFieldsProps {
variant?: 'ps' | 'mobile'; variant?: 'ps' | 'mobile';
@@ -36,7 +37,7 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
<div> <div>
{isMobile && <label className="mobile-field-label"></label>} {isMobile && <label className="mobile-field-label"></label>}
{/* {!isMobile && <label className="ps-field-label">范围</label>} */} {/* {!isMobile && <label className="ps-field-label">范围</label>} */}
<select <FieldSelect
value={exportRange.mode} value={exportRange.mode}
disabled={disabled} disabled={disabled}
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })} 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="odd"></option>
<option value="even"></option> <option value="even"></option>
<option value="custom"></option> <option value="custom"></option>
</select> </FieldSelect>
</div> </div>
{exportRange.mode === 'custom' && ( {exportRange.mode === 'custom' && (
<div> <div>

View File

@@ -6,6 +6,7 @@ import { VariableMappingPanel } from './VariableMappingPanel';
import { PaperSettingsPanel, PageInput } from './PreviewGrid'; import { PaperSettingsPanel, PageInput } from './PreviewGrid';
import { CollapsiblePanelSection } from './CollapsiblePanelSection'; import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { DataRangeFields } from './DataRangeFields'; import { DataRangeFields } from './DataRangeFields';
import { FieldSelect } from './PsSelect';
interface ExportSidebarProps { interface ExportSidebarProps {
importData: ImportData | null; importData: ImportData | null;
@@ -220,7 +221,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
> >
<div> <div>
<label className="ps-field-label">线</label> <label className="ps-field-label">线</label>
<select <FieldSelect
value={cutLine.style} value={cutLine.style}
onChange={(e) => onChange={(e) =>
onChangeCutLine({ onChangeCutLine({
@@ -232,7 +233,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
> >
<option value="dashed">线</option> <option value="dashed">线</option>
<option value="solid">线</option> <option value="solid">线</option>
</select> </FieldSelect>
</div> </div>
<div> <div>
<label className="ps-field-label">线 (mm)</label> <label className="ps-field-label">线 (mm)</label>

View File

@@ -8,6 +8,7 @@ import {
RecordRow, RecordRow,
} from '../types'; } from '../types';
import { PaperSettingsPanel, PageInput } from './PreviewGrid'; import { PaperSettingsPanel, PageInput } from './PreviewGrid';
import { FieldSelect } from './PsSelect';
interface MobileExportLayoutPanelProps { interface MobileExportLayoutPanelProps {
template: LabelTemplate; template: LabelTemplate;
@@ -59,7 +60,7 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
> >
<div> <div>
<label className="ps-field-label">线</label> <label className="ps-field-label">线</label>
<select <FieldSelect
value={cutLine.style} value={cutLine.style}
onChange={(e) => onChange={(e) =>
onChangeCutLine({ onChangeCutLine({
@@ -71,7 +72,7 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
> >
<option value="dashed">线</option> <option value="dashed">线</option>
<option value="solid">线</option> <option value="solid">线</option>
</select> </FieldSelect>
</div> </div>
<div> <div>
<label className="ps-field-label">线 (mm)</label> <label className="ps-field-label">线 (mm)</label>

View File

@@ -17,6 +17,7 @@ import {
import { CanvasLabelImage } from './CanvasLabelImage'; import { CanvasLabelImage } from './CanvasLabelImage';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { FieldSelect } from './PsSelect';
import { FileDown, RefreshCcw, X } from 'lucide-react'; import { FileDown, RefreshCcw, X } from 'lucide-react';
interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> { interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
@@ -316,7 +317,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
<h4 className={sectionTitleClass}></h4> <h4 className={sectionTitleClass}></h4>
<div> <div>
<label className={labelClass}></label> <label className={labelClass}></label>
<select <FieldSelect
value={draftPaper.type} value={draftPaper.type}
onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)} onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)}
className={selectClass} className={selectClass}
@@ -325,7 +326,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
<option value="A5">A5 (148×210 mm)</option> <option value="A5">A5 (148×210 mm)</option>
<option value="A6">A6 (105×148 mm)</option> <option value="A6">A6 (105×148 mm)</option>
<option value="custom"></option> <option value="custom"></option>
</select> </FieldSelect>
</div> </div>
{draftPaper.type === 'custom' && ( {draftPaper.type === 'custom' && (
@@ -499,14 +500,14 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
</div> </div>
<div> <div>
<label className={labelClass}></label> <label className={labelClass}></label>
<select <FieldSelect
value={draftPaper.flow} value={draftPaper.flow}
onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })} onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })}
className={selectClass} className={selectClass}
> >
<option value="left-right-top-bottom"> </option> <option value="left-right-top-bottom"> </option>
<option value="top-bottom-left-right"> </option> <option value="top-bottom-left-right"> </option>
</select> </FieldSelect>
</div> </div>
</div> </div>

View File

@@ -20,6 +20,7 @@ import {
type AlignMode, type AlignMode,
} from '../elementAlign'; } from '../elementAlign';
import { CollapsiblePanelSection } from './CollapsiblePanelSection'; import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { useIsNarrowScreen } from '../hooks/useIsMobile'; 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 className="text-[10px] font-bold text-[#31a8ff]"></div>
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={isConditionalVisibility(selectedElem) ? 'conditional' : 'always'} value={isConditionalVisibility(selectedElem) ? 'conditional' : 'always'}
onChange={(e) => { onChange={(e) => {
const conditional = e.target.value === 'conditional'; const conditional = e.target.value === 'conditional';
@@ -710,7 +711,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
> >
<option value="always"></option> <option value="always"></option>
<option value="conditional"></option> <option value="conditional"></option>
</select> </FieldSelect>
</div> </div>
{isConditionalVisibility(selectedElem) && (() => { {isConditionalVisibility(selectedElem) && (() => {
const rules = resolveVisibilityRules(selectedElem); const rules = resolveVisibilityRules(selectedElem);
@@ -962,7 +963,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={selectedElem.textFormat || 'text'} value={selectedElem.textFormat || 'text'}
onChange={(e) => onChange={(e) =>
handleElemChange({ handleElemChange({
@@ -980,7 +981,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="number"></option> <option value="number"></option>
<option value="percent"></option> <option value="percent"></option>
<option value="currency"> (¥)</option> <option value="currency"> (¥)</option>
</select> </FieldSelect>
</div> </div>
{(selectedElem.textFormat === 'number' || {(selectedElem.textFormat === 'number' ||
selectedElem.textFormat === 'percent' || selectedElem.textFormat === 'percent' ||
@@ -1004,7 +1005,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={selectedElem.numberPartDisplay ?? 'full'} value={selectedElem.numberPartDisplay ?? 'full'}
onChange={(e) => onChange={(e) =>
handleElemChange({ handleElemChange({
@@ -1020,7 +1021,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="full"></option> <option value="full"></option>
<option value="integer"> 19.9 19</option> <option value="integer"> 19.9 19</option>
<option value="fraction"> 19.9 .9</option> <option value="fraction"> 19.9 .9</option>
</select> </FieldSelect>
<p className="text-[9px] text-[#666] mt-1 leading-relaxed"> <p className="text-[9px] text-[#666] mt-1 leading-relaxed">
19.9 19.9
</p> </p>
@@ -1045,7 +1046,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div> </div>
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={selectedElem.fontFamily} value={selectedElem.fontFamily}
onChange={(e) => onChange={(e) =>
handleElemChange({ handleElemChange({
@@ -1058,7 +1059,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="sans"> (Sans)</option> <option value="sans"> (Sans)</option>
<option value="serif"> (Serif)</option> <option value="serif"> (Serif)</option>
<option value="mono"> (Mono)</option> <option value="mono"> (Mono)</option>
</select> </FieldSelect>
</div> </div>
</div> </div>
@@ -1303,7 +1304,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</h5> </h5>
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={selectedElem.barcodeFormat} value={selectedElem.barcodeFormat}
onChange={(e) => onChange={(e) =>
handleElemChange({ handleElemChange({
@@ -1317,7 +1318,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="CODE39">CODE 39 ()</option> <option value="CODE39">CODE 39 ()</option>
<option value="EAN13">EAN 13 ( 13)</option> <option value="EAN13">EAN 13 ( 13)</option>
<option value="ITF">ITF ()</option> <option value="ITF">ITF ()</option>
</select> </FieldSelect>
</div> </div>
<label className="flex items-center gap-2 cursor-pointer mt-1"> <label className="flex items-center gap-2 cursor-pointer mt-1">
@@ -1341,7 +1342,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</h5> </h5>
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={selectedElem.imageFit ?? 'contain'} value={selectedElem.imageFit ?? 'contain'}
onChange={(e) => onChange={(e) =>
handleElemChange({ handleElemChange({
@@ -1354,7 +1355,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="contain"> (contain)</option> <option value="contain"> (contain)</option>
<option value="cover"> (cover)</option> <option value="cover"> (cover)</option>
<option value="fill"> (fill)</option> <option value="fill"> (fill)</option>
</select> </FieldSelect>
</div> </div>
</div> </div>
)} )}

237
src/components/PsSelect.tsx Normal file
View 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>
);
};

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { TemplateElement } from '../types'; import { TemplateElement } from '../types';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { FieldSelect } from './PsSelect';
interface ValueExtractFieldsProps { interface ValueExtractFieldsProps {
elem: TemplateElement; elem: TemplateElement;
@@ -21,7 +22,7 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={extractMode} value={extractMode}
onChange={(e) => { onChange={(e) => {
const mode = e.target.value as 'none' | 'split' | 'prefix' | 'suffix'; 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="split"></option>
<option value="prefix"> N </option> <option value="prefix"> N </option>
<option value="suffix"> N </option> <option value="suffix"> N </option>
</select> </FieldSelect>
</div> </div>
{extractMode === 'split' && ( {extractMode === 'split' && (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">

View File

@@ -1,5 +1,10 @@
import React from 'react'; 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 { interface VariableMappingPanelProps {
variables: string[]; variables: string[];
@@ -110,19 +115,22 @@ export const VariableMappingPanel: React.FC<VariableMappingPanelProps> = ({
isMobile ? 'text-sm text-indigo-600' : isPs ? 'text-[#31a8ff]' : 'text-indigo-700' isMobile ? 'text-sm text-indigo-600' : isPs ? 'text-[#31a8ff]' : 'text-indigo-700'
}`} }`}
>{`{${varName}}`}</span> >{`{${varName}}`}</span>
<select <FieldSelect
value={mappedCol} value={mappedCol}
onChange={(e) => onChangeMapping({ ...mapping, [varName]: e.target.value })} onChange={(e) => onChangeMapping({ ...mapping, [varName]: e.target.value })}
className={selectClass} className={selectClass}
> >
<option value=""></option> <option value=""></option>
<option value={VARIABLE_MAPPING_USE_DEFAULT}>{useDefaultLabel}</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) => ( {columns.map((col) => (
<option key={col} value={col}> <option key={col} value={col}>
{col} {col}
</option> </option>
))} ))}
</select> </FieldSelect>
{mappedCol === VARIABLE_MAPPING_USE_DEFAULT && !defaultVal && ( {mappedCol === VARIABLE_MAPPING_USE_DEFAULT && !defaultVal && (
<p <p
className={ className={

View File

@@ -7,6 +7,7 @@ import {
VISIBILITY_RULE_TARGET_LABELS, VISIBILITY_RULE_TARGET_LABELS,
} from '../types'; } from '../types';
import { visibilityOperatorNeedsValue } from '../utils'; import { visibilityOperatorNeedsValue } from '../utils';
import { FieldSelect } from './PsSelect';
export interface VisibilityRuleDraft { export interface VisibilityRuleDraft {
target: VisibilityRuleTarget; target: VisibilityRuleTarget;
@@ -32,7 +33,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={target} value={target}
onChange={(e) => { onChange={(e) => {
const nextTarget = e.target.value as VisibilityRuleTarget; const nextTarget = e.target.value as VisibilityRuleTarget;
@@ -48,12 +49,12 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
{VISIBILITY_RULE_TARGET_LABELS[t]} {VISIBILITY_RULE_TARGET_LABELS[t]}
</option> </option>
))} ))}
</select> </FieldSelect>
</div> </div>
{target === 'variable' ? ( {target === 'variable' ? (
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={rule.variable} value={rule.variable}
onChange={(e) => onChange({ variable: e.target.value })} onChange={(e) => onChange({ variable: e.target.value })}
className="ps-field text-[11px] w-full mt-0.5" className="ps-field text-[11px] w-full mt-0.5"
@@ -64,7 +65,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
{v} {v}
</option> </option>
))} ))}
</select> </FieldSelect>
{variableOptions.length === 0 && ( {variableOptions.length === 0 && (
<p className="text-[9px] text-[#666] mt-1 leading-relaxed"> <p className="text-[9px] text-[#666] mt-1 leading-relaxed">
@@ -78,7 +79,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
)} )}
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<select <FieldSelect
value={rule.operator} value={rule.operator}
onChange={(e) => { onChange={(e) => {
const operator = e.target.value as VisibilityOperator; const operator = e.target.value as VisibilityOperator;
@@ -94,7 +95,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
{VISIBILITY_OPERATOR_LABELS[op]} {VISIBILITY_OPERATOR_LABELS[op]}
</option> </option>
))} ))}
</select> </FieldSelect>
</div> </div>
{visibilityOperatorNeedsValue(rule.operator) && ( {visibilityOperatorNeedsValue(rule.operator) && (
<div> <div>

View File

@@ -340,6 +340,89 @@ body {
font-family: var(--font-mono); 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 { .ps-field:focus {
border-color: var(--color-ps-accent); border-color: var(--color-ps-accent);
outline: none; outline: none;
@@ -1093,6 +1176,15 @@ body {
appearance: none; 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 { .mobile-field:focus {
outline: none; outline: none;
border-color: #818cf8; border-color: #818cf8;

View File

@@ -22,6 +22,11 @@ const VARIABLE_MAPPING_KEY = 'label_var_column_mapping_v2';
/** 变量映射:使用模板中配置的默认值(非数据列) */ /** 变量映射:使用模板中配置的默认值(非数据列) */
export const VARIABLE_MAPPING_USE_DEFAULT = '__use_default__'; 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 { export function toDataSourceMeta(data: ImportData): DataSourceMeta {
return { return {
fileName: data.fileName, fileName: data.fileName,
@@ -111,19 +116,25 @@ export function isVariableMappingConfigured(
const mapped = mapping[varName]; const mapped = mapping[varName];
if (!mapped) return false; if (!mapped) return false;
if (mapped === VARIABLE_MAPPING_USE_DEFAULT) return true; if (mapped === VARIABLE_MAPPING_USE_DEFAULT) return true;
if (mapped === VARIABLE_MAPPING_USE_ROW_INDEX) return true;
return columns.includes(mapped); return columns.includes(mapped);
} }
/** 按映射将数据行中的列值填入模板变量键;「使用默认值」时写入 variableDefaults */ /** 按映射将数据行中的列值填入模板变量键;「使用默认值」/「数据序号」时写入对应值 */
export function applyRowVariableMapping( export function applyRowVariableMapping(
row: RecordRow, row: RecordRow,
mapping: Record<string, string>, mapping: Record<string, string>,
variables: string[], variables: string[],
variableDefaults?: Record<string, string> | null variableDefaults?: Record<string, string> | null,
rowIndex: number = 0
): RecordRow { ): RecordRow {
const result = { ...row }; const result = { ...row };
for (const varName of variables) { for (const varName of variables) {
const mapped = mapping[varName]; const mapped = mapping[varName];
if (mapped === VARIABLE_MAPPING_USE_ROW_INDEX) {
result[varName] = String(rowIndex + 1);
continue;
}
if (mapped === VARIABLE_MAPPING_USE_DEFAULT) { if (mapped === VARIABLE_MAPPING_USE_DEFAULT) {
const def = variableDefaults?.[varName]; const def = variableDefaults?.[varName];
if (def !== undefined && String(def).trim() !== '') { if (def !== undefined && String(def).trim() !== '') {