react-native-x-components 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,7 +32,9 @@ export function XSignatureSkia({ value, onChange, buttonText = '签名', strokeC
32
32
  const t = useXTheme();
33
33
  const { t: i18n } = useXLocale();
34
34
  const insets = useSafeAreaInsets();
35
- const signatureValue = value?.characters ? value : emptyValue();
35
+ /** 非受控兜底:未传 value/onChange 时内部自持状态(示例/轻量场景开箱即用) */
36
+ const [innerValue, setInnerValue] = useState(null);
37
+ const signatureValue = value !== undefined ? (value?.characters ? value : emptyValue()) : (innerValue ?? emptyValue());
36
38
  /** 预览画布 ref(完成时截图用) */
37
39
  const previewCanvasRef = useCanvasRef();
38
40
  const [visible, setVisible] = useState(false);
@@ -43,8 +45,14 @@ export function XSignatureSkia({ value, onChange, buttonText = '签名', strokeC
43
45
  /** 预览:字符集拼到预览画布 */
44
46
  const previewStrokes = useMemo(() => transformCharacters(signatureValue.characters, previewSize), [previewSize, signatureValue.characters]);
45
47
  const emitCharacters = useCallback((characters) => {
46
- onChange?.(characters.length ? { version: 1, characters } : null);
47
- }, [onChange]);
48
+ const next = characters.length ? { version: 1, characters } : null;
49
+ if (value !== undefined) {
50
+ onChange?.(next);
51
+ }
52
+ else {
53
+ setInnerValue(next);
54
+ }
55
+ }, [value, onChange]);
48
56
  /** 一笔画完 */
49
57
  const handleStrokeComplete = useCallback((points, size) => {
50
58
  setCurrentStrokes(strokes => [
@@ -105,12 +113,12 @@ export function XSignatureSkia({ value, onChange, buttonText = '签名', strokeC
105
113
  setVisible(false);
106
114
  }, [signatureValue.characters, currentStrokes, emitCharacters, previewCanvasRef, onExport]);
107
115
  /** 签名板内容(XPullView 内) */
108
- const board = (_jsxs(View, { style: [styles.board, { backgroundColor: t.colorBgContainer, paddingBottom: Math.max(insets.bottom, 8) }], children: [_jsxs(ScrollView, { horizontal: true, style: [styles.characterStrip, { backgroundColor: t.colorBgLayout }], contentContainerStyle: styles.characterStripContent, children: [_jsx(Text, { style: [styles.savedCount, { color: t.colorTextTertiary }], allowFontScaling: false, children: i18n('savedChars', { n: signatureValue.characters.length }) }), signatureValue.characters.map(character => (_jsx(Pressable, { onLongPress: () => handleRemoveCharacter(character.id), style: [styles.characterChip, { borderColor: t.colorBorder, backgroundColor: t.colorBgContainer }], children: _jsx(CharacterChip, { character: character }) }, character.id)))] }), _jsxs(View, { style: styles.signArea, children: [_jsx(SignatureCanvas, { strokes: currentStrokes, touchEnabled: true, strokeColor: strokeColor, strokeWidth: strokeWidth, onStrokeComplete: handleStrokeComplete, onLayoutSize: setCanvasSize }), canvasSize.width > 0 && canvasSize.height > 0 && (_jsxs(Svg, { pointerEvents: 'none', width: canvasSize.width, height: canvasSize.height, style: styles.grid, children: [_jsx(Line, { x1: '0%', y1: '50%', x2: '100%', y2: '50%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '50%', y1: '0%', x2: '50%', y2: '100%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '0%', y1: '0%', x2: '100%', y2: '100%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '100%', y1: '0%', x2: '0%', y2: '100%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] })] }))] }), _jsxs(View, { style: styles.footer, children: [_jsx(XButton, { type: 'text', onPress: () => setVisible(false), children: i18n('close') }), _jsx(XButton, { type: 'text', onPress: handleClearCurrent, children: i18n('rewrite') }), _jsx(XButton, { type: 'default', size: 'small', onPress: handleSaveCharacter, children: i18n('saveChar') }), _jsx(XButton, { type: 'text', danger: true, onPress: handleClearAll, children: i18n('clear') }), _jsx(XButton, { type: 'primary', size: 'small', onPress: handleDone, children: i18n('done') })] })] }));
116
+ const board = (_jsxs(View, { style: [styles.board, { backgroundColor: t.colorBgContainer, paddingBottom: Math.max(insets.bottom, 8) }], children: [_jsxs(ScrollView, { horizontal: true, style: [styles.characterStrip, { backgroundColor: t.colorBgLayout }], contentContainerStyle: styles.characterStripContent, children: [_jsx(Text, { style: [styles.savedCount, { color: t.colorTextTertiary }], allowFontScaling: false, children: i18n('savedChars', { n: signatureValue.characters.length }) }), signatureValue.characters.map(character => (_jsx(Pressable, { onLongPress: () => handleRemoveCharacter(character.id), style: [styles.characterChip, { borderColor: t.colorBorder, backgroundColor: t.colorBgContainer }], children: _jsx(CharacterChip, { character: character }) }, character.id)))] }), _jsxs(View, { style: [styles.signArea, { height: 320 }], children: [_jsx(SignatureCanvas, { strokes: currentStrokes, touchEnabled: true, strokeColor: strokeColor, strokeWidth: strokeWidth, onStrokeComplete: handleStrokeComplete, onLayoutSize: setCanvasSize }), canvasSize.width > 0 && canvasSize.height > 0 && (_jsxs(Svg, { pointerEvents: 'none', width: canvasSize.width, height: canvasSize.height, style: styles.grid, children: [_jsx(Line, { x1: '0%', y1: '50%', x2: '100%', y2: '50%', stroke: '#DDDDDD', strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '50%', y1: '0%', x2: '50%', y2: '100%', stroke: '#DDDDDD', strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '0%', y1: '0%', x2: '100%', y2: '100%', stroke: '#DDDDDD', strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '100%', y1: '0%', x2: '0%', y2: '100%', stroke: '#DDDDDD', strokeWidth: '1', strokeDasharray: [4, 4] })] }))] }), _jsxs(View, { style: styles.footer, children: [_jsx(XButton, { type: 'text', onPress: () => setVisible(false), children: i18n('close') }), _jsx(XButton, { type: 'text', onPress: handleClearCurrent, children: i18n('rewrite') }), _jsx(XButton, { type: 'default', size: 'small', onPress: handleSaveCharacter, children: i18n('saveChar') }), _jsx(XButton, { type: 'text', danger: true, onPress: handleClearAll, children: i18n('clear') }), _jsx(XButton, { type: 'primary', size: 'small', onPress: handleDone, children: i18n('done') })] })] }));
109
117
  // inline 模式:外部自己控制 XPullView(board 由 renderBoard 暴露)——当前版本仅内部用
110
118
  if (inline) {
111
119
  return board;
112
120
  }
113
- return (_jsxs(View, { style: styles.container, children: [_jsx(XButton, { type: 'primary', style: styles.openButton, onPress: () => setVisible(true), children: buttonText }), _jsxs(View, { style: [styles.preview, { borderColor: t.colorBorder }], children: [_jsx(SignatureCanvas, { strokes: previewStrokes, strokeColor: strokeColor, strokeWidth: strokeWidth, onLayoutSize: setPreviewSize, canvasRef: previewCanvasRef }), previewStrokes.length === 0 && (_jsx(Text, { style: [styles.placeholder, { color: t.colorTextTertiary }], children: i18n('signHint') }))] }), _jsx(XPullView, { visible: visible, onClose: () => setVisible(false), side: 'bottom', overlayOpacity: 0.5, children: _jsx(View, { style: { height: '88%' }, children: board }) })] }));
121
+ return (_jsxs(View, { style: styles.container, children: [_jsx(XButton, { type: 'primary', style: styles.openButton, onPress: () => setVisible(true), children: buttonText }), _jsxs(View, { style: [styles.preview, { borderColor: t.colorBorder }], children: [_jsx(SignatureCanvas, { strokes: previewStrokes, strokeColor: strokeColor, strokeWidth: strokeWidth, onLayoutSize: setPreviewSize, canvasRef: previewCanvasRef }), previewStrokes.length === 0 && (_jsx(Text, { style: [styles.placeholder, { color: t.colorTextTertiary }], children: i18n('signHint') }))] }), _jsx(XPullView, { visible: visible, onClose: () => setVisible(false), side: 'bottom', overlayOpacity: 0.5, children: _jsx(View, { style: { height: 520, paddingBottom: 10 }, children: board }) })] }));
114
122
  }
115
123
  /** 单个已存字的 chip 缩略渲染 */
116
124
  function CharacterChip({ character }) {
@@ -17,7 +17,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
17
  import { useCallback, useState } from 'react';
18
18
  import { ImageFormat, useCanvasRef } from '@shopify/react-native-skia';
19
19
  import { StyleSheet, Text, View } from 'react-native';
20
- import Svg, { Line } from 'react-native-svg';
21
20
  import { XButton } from '../XButton';
22
21
  import { useXTheme } from '../theme';
23
22
  import { useXLocale } from '../XLocale';
@@ -59,7 +58,7 @@ export function XSignature({ height = 200, strokeColor = '#111111', strokeWidth
59
58
  if (image)
60
59
  onExport?.(image.encodeToBase64(ImageFormat.PNG));
61
60
  }, [strokes.length, canvasRef, onExport]);
62
- return (_jsxs(View, { style: [styles.container, style], children: [_jsxs(View, { style: [styles.paper, { height, borderColor: t.colorBorder, borderRadius: t.borderRadiusXL }], children: [_jsx(SignatureCanvas, { strokes: strokes, touchEnabled: !disabled, strokeColor: strokeColor, strokeWidth: strokeWidth, onStrokeComplete: handleStrokeComplete, onLayoutSize: setSize, canvasRef: canvasRef }), size.width > 0 && (_jsxs(Svg, { pointerEvents: 'none', width: size.width, height: size.height, style: styles.grid, children: [_jsx(Line, { x1: '0%', y1: '50%', x2: '100%', y2: '50%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] }), _jsx(Line, { x1: '50%', y1: '0%', x2: '50%', y2: '100%', stroke: t.colorSplit, strokeWidth: '1', strokeDasharray: [4, 4] })] })), strokes.length === 0 && (_jsx(Text, { pointerEvents: 'none', style: [styles.placeholder, { color: t.colorTextTertiary }], children: i18n('signHint') }))] }), _jsxs(View, { style: styles.actions, children: [_jsx(XButton, { size: 'small', onPress: handleUndo, disabled: disabled || !strokes.length, children: i18n('undo') }), _jsx(XButton, { size: 'small', danger: true, type: 'text', onPress: handleClear, disabled: disabled || !strokes.length, children: i18n('clear') }), _jsx(XButton, { size: 'small', type: 'primary', onPress: handleDone, disabled: disabled || !strokes.length, children: i18n('done') })] })] }));
61
+ return (_jsxs(View, { style: [styles.container, style], children: [_jsxs(View, { style: [styles.paper, { height, borderColor: t.colorBorder, borderRadius: t.borderRadiusXL }], children: [_jsx(SignatureCanvas, { strokes: strokes, touchEnabled: !disabled, strokeColor: strokeColor, strokeWidth: strokeWidth, onStrokeComplete: handleStrokeComplete, onLayoutSize: setSize, canvasRef: canvasRef }), strokes.length === 0 && (_jsx(Text, { pointerEvents: 'none', style: [styles.placeholder, { color: t.colorTextTertiary }], children: i18n('signHint') }))] }), _jsxs(View, { style: styles.actions, children: [_jsx(XButton, { size: 'small', onPress: handleUndo, disabled: disabled || !strokes.length, children: i18n('undo') }), _jsx(XButton, { size: 'small', danger: true, type: 'text', onPress: handleClear, disabled: disabled || !strokes.length, children: i18n('clear') }), _jsx(XButton, { size: 'small', type: 'primary', onPress: handleDone, disabled: disabled || !strokes.length, children: i18n('done') })] })] }));
63
62
  }
64
63
  const styles = StyleSheet.create({
65
64
  container: {
@@ -0,0 +1,20 @@
1
+ export interface XBrandPreset {
2
+ name: string;
3
+ /** 浅色主色 */
4
+ primary: string;
5
+ /** 暗黑主色 */
6
+ primaryDark: string;
7
+ /** 弱背景色(antd colorPrimaryBg) */
8
+ primaryBg: string;
9
+ }
10
+ export declare const X_BRAND_PRESETS: XBrandPreset[];
11
+ interface XBrandState {
12
+ brand: XBrandPreset;
13
+ setXBrand: (brand: XBrandPreset) => void;
14
+ setXBrandByName: (name: string) => void;
15
+ }
16
+ export declare const useXBrandStore: import("zustand").UseBoundStore<import("zustand").StoreApi<XBrandState>>;
17
+ /** 命令式:切换品牌色 */
18
+ export declare function setXBrand(brand: XBrandPreset): void;
19
+ export declare function setXBrandByName(name: string): void;
20
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ============================================================================
3
+ * XBrand —— 运行时主题色切换
4
+ * ============================================================================
5
+ * 一组预设品牌色 + zustand 全局 store。setXBrand(name) 立即生效:
6
+ * 浅/深两套 token 里的 colorPrimary / colorPrimaryActive / colorPrimaryBg
7
+ * 被覆盖。组件统一 useXTheme() 取色,不需要额外订阅。
8
+ * ============================================================================
9
+ */
10
+ import { create } from 'zustand';
11
+ export const X_BRAND_PRESETS = [
12
+ { name: '海洋蓝', primary: '#2080F0', primaryDark: '#3C93F5', primaryBg: 'rgba(32,128,240,0.18)' },
13
+ { name: '薰衣紫', primary: '#7C3AED', primaryDark: '#A78BFA', primaryBg: 'rgba(124,58,237,0.18)' },
14
+ { name: '翡翠绿', primary: '#16A34A', primaryDark: '#4ADE80', primaryBg: 'rgba(22,163,74,0.18)' },
15
+ { name: '暖阳橙', primary: '#F97316', primaryDark: '#FB923C', primaryBg: 'rgba(249,115,22,0.18)' },
16
+ ];
17
+ const DEFAULT_BRAND = X_BRAND_PRESETS[0];
18
+ export const useXBrandStore = create(set => ({
19
+ brand: DEFAULT_BRAND,
20
+ setXBrand: brand => set({ brand }),
21
+ setXBrandByName: name => {
22
+ const found = X_BRAND_PRESETS.find(p => p.name === name);
23
+ if (found)
24
+ set({ brand: found });
25
+ },
26
+ }));
27
+ /** 命令式:切换品牌色 */
28
+ export function setXBrand(brand) {
29
+ useXBrandStore.getState().setXBrand(brand);
30
+ }
31
+ export function setXBrandByName(name) {
32
+ useXBrandStore.getState().setXBrandByName(name);
33
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ============================================================================
3
+ * ThemeControls —— 暗黑/主题色/语言 三个切换按钮组
4
+ * ============================================================================
5
+ * 在 3 个位置使用:
6
+ * 1. 介绍页(app/index.tsx)顶部
7
+ * 2. 组件页(app/components/index.tsx)Stack headerRight
8
+ * 3. 我的/关于页(app/mine.tsx)顶部
9
+ *
10
+ * props:
11
+ * compact?: boolean true 时紧凑(用于 headerRight 单行)
12
+ * inline?: boolean true 时背景透明、无边框(用于页面顶部)
13
+ * ============================================================================
14
+ */
15
+ import React from 'react';
16
+ export interface ThemeControlsProps {
17
+ compact?: boolean;
18
+ style?: any;
19
+ }
20
+ /** 三个按钮组合 */
21
+ export declare function ThemeControls({ compact, style }: ThemeControlsProps): React.JSX.Element;
22
+ export default ThemeControls;
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import { useXTheme, useXThemeMode, setXThemeMode } from '../theme';
4
+ import { useXBrandStore, setXBrandByName, X_BRAND_PRESETS } from './BrandColor';
5
+ import { showXActionSheet } from '../XActionSheet/global';
6
+ import { useXLocale, setXLocale } from '../XLocale';
7
+ /**
8
+ * 暗黑切换:三态循环 📱跟随系统 → 🌙暗 → ☀️亮 → 📱。
9
+ * 用户点过两态按钮会永久脱离系统跟随(bug 根因),三态保证能回到"自动"。
10
+ */
11
+ function ThemeModeButton({ compact }) {
12
+ const t = useXTheme();
13
+ const mode = useXThemeMode();
14
+ const next = mode === 'system' ? 'dark' : mode === 'dark' ? 'light' : 'system';
15
+ return (_jsx(Pressable, { onPress: () => setXThemeMode(next), hitSlop: 8, style: ({ pressed }) => [
16
+ compact ? styles.btnCompact : styles.btn,
17
+ { borderColor: t.colorBorder, backgroundColor: pressed ? t.colorBgLayout : 'transparent' },
18
+ ], children: _jsx(Text, { style: [compact ? styles.icon : styles.iconBig, { color: t.colorText }], children: mode === 'system' ? '📱' : mode === 'dark' ? '🌙' : '☀️' }) }));
19
+ }
20
+ /** 主题色选择:点击弹 ActionSheet(当前色打勾),选中即时生效 */
21
+ function BrandColorButton({ compact }) {
22
+ const t = useXTheme();
23
+ const brand = useXBrandStore(s => s.brand);
24
+ return (_jsx(Pressable, { onPress: async () => {
25
+ const picked = await showXActionSheet({
26
+ title: '选择主题色',
27
+ options: X_BRAND_PRESETS.map(p => ({ label: `⬤ ${p.name}`, value: p.name })),
28
+ });
29
+ if (picked)
30
+ setXBrandByName(picked);
31
+ }, hitSlop: 8, style: ({ pressed }) => [
32
+ compact ? styles.btnCompact : styles.btn,
33
+ { borderColor: t.colorBorder, backgroundColor: pressed ? t.colorBgLayout : 'transparent' },
34
+ ], children: _jsx(View, { style: {
35
+ width: compact ? 14 : 18,
36
+ height: compact ? 14 : 18,
37
+ borderRadius: compact ? 7 : 9,
38
+ backgroundColor: brand.primary,
39
+ } }) }));
40
+ }
41
+ /** 语言切换:中 ↔ 英 */
42
+ function LocaleButton({ compact }) {
43
+ const t = useXTheme();
44
+ const { locale } = useXLocale();
45
+ const next = locale === 'zh-CN' ? 'en-US' : 'zh-CN';
46
+ return (_jsx(Pressable, { onPress: () => setXLocale(next), hitSlop: 8, style: ({ pressed }) => [
47
+ compact ? styles.btnCompact : styles.btn,
48
+ { borderColor: t.colorBorder, backgroundColor: pressed ? t.colorBgLayout : 'transparent' },
49
+ ], children: _jsx(Text, { style: [compact ? styles.icon : styles.iconBig, { color: t.colorText, fontWeight: '600' }], children: locale === 'zh-CN' ? 'EN' : '中' }) }));
50
+ }
51
+ /** 三个按钮组合 */
52
+ export function ThemeControls({ compact, style }) {
53
+ return (_jsxs(View, { style: [compact ? styles.rowCompact : styles.row, compact && styles.rowFlex], children: [_jsx(ThemeModeButton, { compact: compact }), _jsx(BrandColorButton, { compact: compact }), _jsx(LocaleButton, { compact: compact })] }));
54
+ }
55
+ const styles = StyleSheet.create({
56
+ row: {
57
+ flexDirection: 'row',
58
+ gap: 10,
59
+ alignItems: 'center',
60
+ },
61
+ rowCompact: {
62
+ flexDirection: 'row',
63
+ gap: 6,
64
+ alignItems: 'center',
65
+ },
66
+ rowFlex: {
67
+ flex: 1,
68
+ },
69
+ btn: {
70
+ width: 44,
71
+ height: 36,
72
+ borderWidth: StyleSheet.hairlineWidth,
73
+ borderRadius: 18,
74
+ alignItems: 'center',
75
+ justifyContent: 'center',
76
+ },
77
+ btnCompact: {
78
+ width: 32,
79
+ height: 32,
80
+ borderWidth: StyleSheet.hairlineWidth,
81
+ borderRadius: 16,
82
+ alignItems: 'center',
83
+ justifyContent: 'center',
84
+ },
85
+ icon: {
86
+ fontSize: 14,
87
+ },
88
+ iconBig: {
89
+ fontSize: 16,
90
+ },
91
+ });
92
+ export default ThemeControls;
@@ -13,11 +13,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  * ============================================================================
14
14
  */
15
15
  import { useCallback, useRef, useState } from 'react';
16
- import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
16
+ import { Alert, Image, Pressable, StyleSheet, Text, View } from 'react-native';
17
17
  import { useXTheme } from '../theme';
18
18
  import { useXLocale } from '../XLocale';
19
19
  import { useXUploadAdapter } from './provider';
20
- import { compressImage, pickImageFiles } from './helpers';
20
+ import { showXActionSheet } from '../XActionSheet/global';
21
+ import { XConfirmForm } from '../XConfirmForm';
22
+ import { XImagePreviewService } from '../XProviders';
23
+ import { compressImage, pickImageFiles, takePicture } from './helpers';
21
24
  const CELL_GAP = 8;
22
25
  export function XUploadImage({ value, onChange, max = 9, compress = true, compressWidth = 1280, quality = 0.6, disabled = false, columns = 3, adapter: localAdapter, onItemPress, style, }) {
23
26
  const t = useXTheme();
@@ -38,14 +41,32 @@ export function XUploadImage({ value, onChange, max = 9, compress = true, compre
38
41
  setInner(next);
39
42
  onChange?.(next);
40
43
  }, [onChange]);
41
- /** 加号:选图 → 逐张压缩上传(串行,进度独立展示) */
44
+ /**
45
+ * 加号:弹 ActionSheet(拍照/相册/取消),再选 → 压缩 → 串行上传
46
+ */
42
47
  const handleAdd = useCallback(async () => {
43
48
  if (disabled || tasks.length)
44
49
  return;
45
50
  const remaining = max - currentRef.current.length;
46
51
  if (remaining <= 0)
47
52
  return;
48
- const picked = await pickImageFiles({ max: remaining, quality: 0.9 });
53
+ const source = await showXActionSheet({
54
+ title: '选择图片来源',
55
+ options: [
56
+ { label: '📷 拍照', value: 'camera' },
57
+ { label: '🖼️ 从相册选择', value: 'album' },
58
+ { label: '取消', value: null },
59
+ ],
60
+ });
61
+ let picked = [];
62
+ if (source === 'camera') {
63
+ const file = await takePicture({ quality: 0.9 });
64
+ if (file)
65
+ picked = [file];
66
+ }
67
+ else if (source === 'album') {
68
+ picked = await pickImageFiles({ max: remaining, quality: 0.9 });
69
+ }
49
70
  for (const raw of picked) {
50
71
  if (currentRef.current.length >= max)
51
72
  break;
@@ -60,15 +81,24 @@ export function XUploadImage({ value, onChange, max = 9, compress = true, compre
60
81
  }
61
82
  catch (e) {
62
83
  console.warn('[XUploadImage] upload failed:', e);
84
+ Alert.alert('上传失败', String(e.message ?? e));
63
85
  }
64
86
  finally {
65
87
  setTasks(prev => prev.filter(task => task.id !== taskId));
66
88
  }
67
89
  }
68
90
  }, [disabled, max, tasks.length, compress, compressWidth, quality, adapter, emit]);
69
- /** 删除 */
91
+ /** 删除:先弹命令式确认框,确认后才删 */
70
92
  const handleRemove = useCallback((index) => {
71
- emit(currentRef.current.filter((_, i) => i !== index));
93
+ XConfirmForm.show({
94
+ title: '删除图片',
95
+ content: '确定要删除这张图片吗?',
96
+ confirmText: '删除',
97
+ danger: true,
98
+ }).then(ok => {
99
+ if (ok)
100
+ emit(currentRef.current.filter((_, i) => i !== index));
101
+ });
72
102
  }, [emit]);
73
103
  const handleLayout = useCallback((e) => {
74
104
  setGridWidth(e.nativeEvent.layout.width);
@@ -76,16 +106,28 @@ export function XUploadImage({ value, onChange, max = 9, compress = true, compre
76
106
  /** 精确列宽:容器宽 - 间隙后均分 */
77
107
  const cellWidth = gridWidth > 0 ? (gridWidth - CELL_GAP * (columns - 1)) / columns : undefined;
78
108
  const showAdd = !disabled && items.length + tasks.length < max;
79
- return (_jsxs(View, { style: [styles.grid, style], onLayout: handleLayout, children: [items.map((item, index) => (_jsxs(View, { style: [styles.cell, { borderRadius: t.borderRadius, borderColor: t.colorSplit, width: cellWidth }], children: [_jsx(Pressable, { style: styles.cellInner, onPress: () => onItemPress?.(item, index), children: _jsx(Image, { source: { uri: item.url ?? item.uri }, style: styles.image, resizeMode: 'cover' }) }), !disabled && (_jsx(Pressable, { style: styles.remove, onPress: () => handleRemove(index), hitSlop: 6, children: _jsx(Text, { style: styles.removeText, allowFontScaling: false, children: "\u2715" }) }))] }, `${item.objectKey ?? item.url ?? index}`))), tasks.map(task => (_jsx(View, { style: [
109
+ return (_jsxs(View, { style: [styles.grid, style], onLayout: handleLayout, children: [items.map((item, index) => (_jsxs(View, { style: [styles.cell, { borderRadius: t.borderRadius, borderColor: t.colorSplit, width: cellWidth }], children: [_jsx(Pressable, { style: styles.cellInner, onPress: () => {
110
+ // 默认调用内置 XImagePreviewService 预览;也可通过 onItemPress 覆盖
111
+ if (onItemPress) {
112
+ onItemPress(item, index);
113
+ }
114
+ else {
115
+ const urls = items
116
+ .map(it => it?.url ?? it.uri)
117
+ .filter(Boolean);
118
+ const initialIndex = index;
119
+ XImagePreviewService.show({ images: urls, initialIndex });
120
+ }
121
+ }, children: _jsx(Image, { source: { uri: item.url ?? item.uri }, style: styles.image, resizeMode: 'cover' }) }), !disabled && (_jsx(Pressable, { style: styles.remove, onPress: () => handleRemove(index), hitSlop: 6, children: _jsx(Text, { style: styles.removeText, allowFontScaling: false, children: "\u2715" }) }))] }, `${item.objectKey ?? item.url ?? index}`))), tasks.map(task => (_jsx(View, { style: [
80
122
  styles.cell,
81
123
  styles.uploadingCell,
82
124
  { borderRadius: t.borderRadius, borderColor: t.colorSplit, backgroundColor: t.colorBgLayout, width: cellWidth },
83
- ], children: _jsx(Text, { style: [styles.progressText, { color: t.colorPrimary }], allowFontScaling: false, children: i18n('uploadingPercent', { n: task.progress }) }) }, task.id))), showAdd && (_jsx(Pressable, { onPress: handleAdd, style: ({ pressed }) => [
125
+ ], children: _jsx(View, { style: styles.cellCenter, children: _jsx(Text, { style: [styles.progressText, { color: t.colorPrimary }], allowFontScaling: false, children: i18n('uploadingPercent', { n: task.progress }) }) }) }, task.id))), showAdd && (_jsx(Pressable, { onPress: handleAdd, style: ({ pressed }) => [
84
126
  styles.cell,
85
127
  styles.addCell,
86
128
  { borderRadius: t.borderRadius, borderColor: t.colorBorder, width: cellWidth },
87
129
  pressed && { backgroundColor: t.colorBgLayout },
88
- ], children: _jsx(Text, { style: [styles.addText, { color: t.colorTextTertiary }], allowFontScaling: false, children: "\uFF0B" }) }))] }));
130
+ ], children: _jsx(View, { style: styles.cellCenter, children: _jsx(Text, { style: [styles.addText, { color: t.colorTextTertiary }], allowFontScaling: false, children: "\uFF0B" }) }) }))] }));
89
131
  }
90
132
  const styles = StyleSheet.create({
91
133
  grid: {
@@ -125,14 +167,18 @@ const styles = StyleSheet.create({
125
167
  alignItems: 'center',
126
168
  justifyContent: 'center',
127
169
  },
170
+ /** 单元格内容统一用 flex:1 居中容器(修复安卓上加号仅水平居中问题) */
171
+ cellCenter: {
172
+ flex: 1,
173
+ alignItems: 'center',
174
+ justifyContent: 'center',
175
+ },
128
176
  progressText: {
129
177
  fontSize: 12,
130
178
  fontWeight: '600',
131
179
  },
132
180
  addCell: {
133
181
  borderStyle: 'dashed',
134
- alignItems: 'center',
135
- justifyContent: 'center',
136
182
  },
137
183
  addText: {
138
184
  fontSize: 26,
@@ -12,7 +12,9 @@ import { Pressable, StyleSheet, Text, View } from 'react-native';
12
12
  import { useXTheme } from '../theme';
13
13
  import { useXLocale } from '../XLocale';
14
14
  import { useXUploadAdapter } from './provider';
15
- import { pickVideoFile } from './helpers';
15
+ import { showXActionSheet } from '../XActionSheet/global';
16
+ import { pickVideoFile, takeVideo } from './helpers';
17
+ import { XVideoPreview } from '../XVideoPreview';
16
18
  export function XUploadVideo({ value, onChange, videoMaxDuration = 60, disabled = false, adapter: localAdapter, style, }) {
17
19
  const t = useXTheme();
18
20
  const { t: i18n } = useXLocale();
@@ -21,16 +23,32 @@ export function XUploadVideo({ value, onChange, videoMaxDuration = 60, disabled
21
23
  const current = value !== undefined ? value : inner;
22
24
  const [progress, setProgress] = useState(null);
23
25
  const [failed, setFailed] = useState(false);
26
+ /** 视频预览弹层 */
27
+ const [previewUri, setPreviewUri] = useState(null);
24
28
  const emit = useCallback((item) => {
25
29
  setInner(item);
26
30
  onChange?.(item);
27
31
  }, [onChange]);
28
- /** 选择视频 → 上传 */
32
+ /** ActionSheet 拍照 / 相册 → 上传 */
29
33
  const handlePick = useCallback(async () => {
30
34
  if (disabled || progress !== null)
31
35
  return;
32
36
  setFailed(false);
33
- const file = await pickVideoFile({ videoMaxDuration });
37
+ const source = await showXActionSheet({
38
+ title: '选择视频来源',
39
+ options: [
40
+ { label: '🎥 拍视频', value: 'camera' },
41
+ { label: '🎞️ 从相册选择', value: 'album' },
42
+ { label: '取消', value: null },
43
+ ],
44
+ });
45
+ let file = null;
46
+ if (source === 'camera') {
47
+ file = await takeVideo({ videoMaxDuration });
48
+ }
49
+ else if (source === 'album') {
50
+ file = await pickVideoFile({ videoMaxDuration });
51
+ }
34
52
  if (!file)
35
53
  return;
36
54
  setProgress(0);
@@ -46,11 +64,11 @@ export function XUploadVideo({ value, onChange, videoMaxDuration = 60, disabled
46
64
  setProgress(null);
47
65
  }
48
66
  }, [disabled, progress, videoMaxDuration, adapter, emit]);
49
- return (_jsx(View, { style: style, children: current ? (_jsxs(View, { style: [styles.resultBox, { borderColor: t.colorSplit, backgroundColor: t.colorBgLayout, borderRadius: t.borderRadius }], children: [_jsxs(Text, { numberOfLines: 1, style: [styles.resultText, { color: t.colorText }], children: ["\uD83C\uDFAC ", String(current.url ?? current.objectKey ?? '视频')] }), !disabled && (_jsx(Pressable, { onPress: () => emit(null), hitSlop: 6, children: _jsx(Text, { style: [styles.removeText, { color: t.colorError }], children: i18n('delete') }) }))] })) : (_jsx(Pressable, { onPress: handlePick, disabled: disabled, style: ({ pressed }) => [
50
- styles.pickBox,
51
- { borderColor: t.colorBorder, borderRadius: t.borderRadius },
52
- pressed && { backgroundColor: t.colorBgLayout },
53
- ], children: progress !== null ? (_jsx(Text, { style: [styles.pickText, { color: t.colorPrimary }], allowFontScaling: false, children: i18n('uploadingPercent', { n: progress }) })) : (_jsx(Text, { style: [styles.pickText, { color: failed ? t.colorError : t.colorTextSecondary }], allowFontScaling: false, children: failed ? `${i18n('uploadFailed')} · ${i18n('retry')}` : i18n('pleaseSelect') })) })) }));
67
+ return (_jsxs(View, { style: style, children: [current ? (_jsxs(Pressable, { onPress: () => setPreviewUri(current.url ?? current.uri ?? null), style: [styles.resultBox, { borderColor: t.colorSplit, backgroundColor: t.colorBgLayout, borderRadius: t.borderRadius }], children: [_jsxs(Text, { numberOfLines: 1, style: [styles.resultText, { color: t.colorText }], children: ["\uD83C\uDFAC ", String(current.url ?? current.objectKey ?? '视频')] }), !disabled && (_jsx(Pressable, { hitSlop: 6, onPress: () => emit(null), style: styles.deleteBtn, children: _jsx(Text, { style: [styles.removeText, { color: t.colorError }], children: i18n('delete') }) }))] })) : (_jsx(Pressable, { onPress: handlePick, disabled: disabled, style: ({ pressed }) => [
68
+ styles.pickBox,
69
+ { borderColor: t.colorBorder, borderRadius: t.borderRadius },
70
+ pressed && { backgroundColor: t.colorBgLayout },
71
+ ], children: progress !== null ? (_jsx(Text, { style: [styles.pickText, { color: t.colorPrimary }], allowFontScaling: false, children: i18n('uploadingPercent', { n: progress }) })) : (_jsx(Text, { style: [styles.pickText, { color: failed ? t.colorError : t.colorTextSecondary }], allowFontScaling: false, children: failed ? `${i18n('uploadFailed')} · ${i18n('retry')}` : i18n('pleaseSelect') })) })), _jsx(XVideoPreview, { visible: !!previewUri, uri: previewUri ?? '', onClose: () => setPreviewUri(null) })] }));
54
72
  }
55
73
  const styles = StyleSheet.create({
56
74
  resultBox: {
@@ -69,6 +87,9 @@ const styles = StyleSheet.create({
69
87
  removeText: {
70
88
  fontSize: 13,
71
89
  },
90
+ deleteBtn: {
91
+ paddingHorizontal: 4,
92
+ },
72
93
  pickBox: {
73
94
  height: 64,
74
95
  borderWidth: StyleSheet.hairlineWidth,
@@ -9,6 +9,14 @@
9
9
  * ============================================================================
10
10
  */
11
11
  import type { XUploadAdapter, XUploadFile, XUploadResult } from './types';
12
+ /** 拍照 */
13
+ export declare function takePicture(options?: {
14
+ quality?: number;
15
+ }): Promise<XUploadFile | null>;
16
+ /** 拍视频 */
17
+ export declare function takeVideo(options?: {
18
+ videoMaxDuration?: number;
19
+ }): Promise<XUploadFile | null>;
12
20
  /** 选图(可多选),返回本机文件列表 */
13
21
  export declare function pickImageFiles(options?: {
14
22
  /** 最多可选,默认 9 */
@@ -21,6 +21,29 @@ function toUploadFile(asset) {
21
21
  size: asset.fileSize ?? undefined,
22
22
  };
23
23
  }
24
+ /** 拍照 */
25
+ export async function takePicture(options) {
26
+ // 拍照权限(Android 自动授予,iOS 第一次会弹)
27
+ await ImagePicker.requestCameraPermissionsAsync();
28
+ const res = await ImagePicker.launchCameraAsync({
29
+ mediaTypes: ['images'],
30
+ quality: options?.quality ?? 0.9,
31
+ });
32
+ if (res.canceled || !res.assets.length)
33
+ return null;
34
+ return toUploadFile(res.assets[0]);
35
+ }
36
+ /** 拍视频 */
37
+ export async function takeVideo(options) {
38
+ await ImagePicker.requestCameraPermissionsAsync();
39
+ const res = await ImagePicker.launchCameraAsync({
40
+ mediaTypes: ['videos'],
41
+ videoMaxDuration: options?.videoMaxDuration ?? 60,
42
+ });
43
+ if (res.canceled || !res.assets.length)
44
+ return null;
45
+ return toUploadFile(res.assets[0]);
46
+ }
24
47
  /** 选图(可多选),返回本机文件列表 */
25
48
  export async function pickImageFiles(options) {
26
49
  const res = await ImagePicker.launchImageLibraryAsync({
@@ -5,7 +5,7 @@ export type { XUploadAdapter, XUploadFile, XUploadResult } from './types';
5
5
  export { createMinioPresignedAdapter, createFormDataUploadAdapter, createMockUploadAdapter } from './adapters';
6
6
  export type { MinioPresignedAdapterOptions, FormDataAdapterOptions } from './adapters';
7
7
  export { XUploadProvider, setXUploadAdapter, getXUploadAdapter, useXUploadAdapter, createMinioAdapter } from './provider';
8
- export { pickImageFiles, pickVideoFile, compressImage, uploadBase64 } from './helpers';
8
+ export { pickImageFiles, pickVideoFile, compressImage, uploadBase64, takePicture, takeVideo } from './helpers';
9
9
  export { XUploadImage } from './XUploadImage';
10
10
  export type { XUploadImageProps } from './XUploadImage';
11
11
  export { XUploadVideo } from './XUploadVideo';
@@ -1,5 +1,5 @@
1
1
  export { createMinioPresignedAdapter, createFormDataUploadAdapter, createMockUploadAdapter } from './adapters';
2
2
  export { XUploadProvider, setXUploadAdapter, getXUploadAdapter, useXUploadAdapter, createMinioAdapter } from './provider';
3
- export { pickImageFiles, pickVideoFile, compressImage, uploadBase64 } from './helpers';
3
+ export { pickImageFiles, pickVideoFile, compressImage, uploadBase64, takePicture, takeVideo } from './helpers';
4
4
  export { XUploadImage } from './XUploadImage';
5
5
  export { XUploadVideo } from './XUploadVideo';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ============================================================================
3
+ * XVideoPreview —— 视频全屏预览(基于 expo-video + XPullView 居中弹层)
4
+ * ============================================================================
5
+ * 用法:受控组件
6
+ * <XVideoPreview visible={visible} onClose={...} uri="..." />
7
+ * XUploadVideo 内部直接复用。
8
+ * ============================================================================
9
+ */
10
+ import React from 'react';
11
+ export interface XVideoPreviewProps {
12
+ visible: boolean;
13
+ onClose: () => void;
14
+ uri: string;
15
+ }
16
+ export declare function XVideoPreview({ visible, onClose, uri }: XVideoPreviewProps): React.JSX.Element;
17
+ export default XVideoPreview;
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ============================================================================
4
+ * XVideoPreview —— 视频全屏预览(基于 expo-video + XPullView 居中弹层)
5
+ * ============================================================================
6
+ * 用法:受控组件
7
+ * <XVideoPreview visible={visible} onClose={...} uri="..." />
8
+ * XUploadVideo 内部直接复用。
9
+ * ============================================================================
10
+ */
11
+ import { useEffect } from 'react';
12
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
13
+ import { useVideoPlayer, VideoView } from 'expo-video';
14
+ import { XPullView } from '../XPullView';
15
+ export function XVideoPreview({ visible, onClose, uri }) {
16
+ const player = useVideoPlayer(uri, p => {
17
+ p.loop = false;
18
+ p.play();
19
+ });
20
+ // 关闭时停止播放 + 卸载时清理
21
+ useEffect(() => {
22
+ return () => {
23
+ try {
24
+ player?.pause();
25
+ }
26
+ catch {
27
+ /* noop */
28
+ }
29
+ };
30
+ }, [player]);
31
+ // 每次打开自动播放
32
+ useEffect(() => {
33
+ if (visible && player) {
34
+ try {
35
+ player.play();
36
+ }
37
+ catch {
38
+ /* noop */
39
+ }
40
+ }
41
+ else if (!visible && player) {
42
+ try {
43
+ player.pause();
44
+ }
45
+ catch {
46
+ /* noop */
47
+ }
48
+ }
49
+ }, [visible, player]);
50
+ return (_jsx(XPullView, { visible: visible, onClose: onClose, side: 'center', overlayOpacity: 0.85, mask: true, children: _jsxs(View, { style: styles.overlay, children: [_jsx(VideoView, { player: player, style: StyleSheet.absoluteFill, contentFit: 'contain', nativeControls: true }), _jsx(Pressable, { onPress: onClose, hitSlop: 12, style: styles.close, children: _jsx(Text, { style: styles.closeText, children: "\u2715" }) })] }) }));
51
+ }
52
+ const styles = StyleSheet.create({
53
+ overlay: {
54
+ width: '100%',
55
+ aspectRatio: 16 / 9,
56
+ maxHeight: '80%',
57
+ backgroundColor: '#000',
58
+ overflow: 'hidden',
59
+ borderRadius: 12,
60
+ },
61
+ close: {
62
+ position: 'absolute',
63
+ top: 12,
64
+ right: 12,
65
+ width: 36,
66
+ height: 36,
67
+ borderRadius: 18,
68
+ alignItems: 'center',
69
+ justifyContent: 'center',
70
+ backgroundColor: 'rgba(0,0,0,0.5)',
71
+ },
72
+ closeText: {
73
+ color: '#fff',
74
+ fontSize: 18,
75
+ fontWeight: '600',
76
+ },
77
+ });
78
+ export default XVideoPreview;
package/dist/index.d.ts CHANGED
@@ -90,8 +90,6 @@ export { XCarousel } from './XCarousel';
90
90
  export type { XCarouselProps, XCarouselItem } from './XCarousel';
91
91
  export { XTabs, XTabPane } from './XTabs';
92
92
  export type { XTabsProps, XTabPaneProps } from './XTabs';
93
- export { XElevator } from './XElevator';
94
- export type { XElevatorProps, XElevatorSection } from './XElevator';
95
93
  export { default as XDropdownMenu, XDropdownMenuItem } from './XDropdownMenu';
96
94
  export type { XDropdownMenuProps, XDropdownMenuItemProps, XMenuOption } from './XDropdownMenu';
97
95
  export { XCalendar } from './XCalendar';
@@ -111,3 +109,8 @@ export type { XSignatureProps } from './XSignature';
111
109
  export { XSignatureSkia } from './XSignature/XSignatureSkia';
112
110
  export type { XSignatureSkiaProps, XSignatureSkiaValue, XSignatureCharacter, XSignatureStroke, } from './XSignature/XSignatureSkia';
113
111
  export * from './XUpload';
112
+ export { ThemeControls } from './XTheme/ThemeControls';
113
+ export { useXBrandStore, setXBrand, setXBrandByName, X_BRAND_PRESETS } from './XTheme/BrandColor';
114
+ export type { XBrandPreset } from './XTheme/BrandColor';
115
+ export { XVideoPreview } from './XVideoPreview';
116
+ export type { XVideoPreviewProps } from './XVideoPreview';