react-native-x-components 0.1.0 → 0.2.0

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.
@@ -0,0 +1,88 @@
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 { useXLocale, setXLocale } from '../XLocale';
6
+ /**
7
+ * 暗黑切换:三态循环 📱跟随系统 → 🌙暗 → ☀️亮 → 📱。
8
+ * 用户点过两态按钮会永久脱离系统跟随(bug 根因),三态保证能回到"自动"。
9
+ */
10
+ function ThemeModeButton({ compact }) {
11
+ const t = useXTheme();
12
+ const mode = useXThemeMode();
13
+ const next = mode === 'system' ? 'dark' : mode === 'dark' ? 'light' : 'system';
14
+ return (_jsx(Pressable, { onPress: () => setXThemeMode(next), hitSlop: 8, style: ({ pressed }) => [
15
+ compact ? styles.btnCompact : styles.btn,
16
+ { borderColor: t.colorBorder, backgroundColor: pressed ? t.colorBgLayout : 'transparent' },
17
+ ], children: _jsx(Text, { style: [compact ? styles.icon : styles.iconBig, { color: t.colorText }], children: mode === 'system' ? '📱' : mode === 'dark' ? '🌙' : '☀️' }) }));
18
+ }
19
+ /** 主题色循环切换:4 套预设品牌色 */
20
+ function BrandColorButton({ compact }) {
21
+ const t = useXTheme();
22
+ const brand = useXBrandStore(s => s.brand);
23
+ return (_jsx(Pressable, { onPress: () => {
24
+ const idx = X_BRAND_PRESETS.findIndex(p => p.name === brand.name);
25
+ const next = X_BRAND_PRESETS[(idx + 1) % X_BRAND_PRESETS.length];
26
+ setXBrandByName(next.name);
27
+ }, hitSlop: 8, style: ({ pressed }) => [
28
+ compact ? styles.btnCompact : styles.btn,
29
+ { borderColor: t.colorBorder, backgroundColor: pressed ? t.colorBgLayout : 'transparent' },
30
+ ], children: _jsx(View, { style: {
31
+ width: compact ? 14 : 18,
32
+ height: compact ? 14 : 18,
33
+ borderRadius: compact ? 7 : 9,
34
+ backgroundColor: brand.primary,
35
+ } }) }));
36
+ }
37
+ /** 语言切换:中 ↔ 英 */
38
+ function LocaleButton({ compact }) {
39
+ const t = useXTheme();
40
+ const { locale } = useXLocale();
41
+ const next = locale === 'zh-CN' ? 'en-US' : 'zh-CN';
42
+ return (_jsx(Pressable, { onPress: () => setXLocale(next), hitSlop: 8, style: ({ pressed }) => [
43
+ compact ? styles.btnCompact : styles.btn,
44
+ { borderColor: t.colorBorder, backgroundColor: pressed ? t.colorBgLayout : 'transparent' },
45
+ ], children: _jsx(Text, { style: [compact ? styles.icon : styles.iconBig, { color: t.colorText, fontWeight: '600' }], children: locale === 'zh-CN' ? 'EN' : '中' }) }));
46
+ }
47
+ /** 三个按钮组合 */
48
+ export function ThemeControls({ compact, style }) {
49
+ 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 })] }));
50
+ }
51
+ const styles = StyleSheet.create({
52
+ row: {
53
+ flexDirection: 'row',
54
+ gap: 10,
55
+ alignItems: 'center',
56
+ },
57
+ rowCompact: {
58
+ flexDirection: 'row',
59
+ gap: 6,
60
+ alignItems: 'center',
61
+ },
62
+ rowFlex: {
63
+ flex: 1,
64
+ },
65
+ btn: {
66
+ width: 44,
67
+ height: 36,
68
+ borderWidth: StyleSheet.hairlineWidth,
69
+ borderRadius: 18,
70
+ alignItems: 'center',
71
+ justifyContent: 'center',
72
+ },
73
+ btnCompact: {
74
+ width: 32,
75
+ height: 32,
76
+ borderWidth: StyleSheet.hairlineWidth,
77
+ borderRadius: 16,
78
+ alignItems: 'center',
79
+ justifyContent: 'center',
80
+ },
81
+ icon: {
82
+ fontSize: 14,
83
+ },
84
+ iconBig: {
85
+ fontSize: 16,
86
+ },
87
+ });
88
+ 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';
package/dist/index.js CHANGED
@@ -90,7 +90,7 @@ export { useXLocale, setXLocale, getXLocale, xT, xMonthTitle, X_WEEKDAYS } from
90
90
  // ---------------------------------------------------------------------------
91
91
  export { XCarousel } from './XCarousel';
92
92
  export { XTabs, XTabPane } from './XTabs';
93
- export { XElevator } from './XElevator';
93
+ // XElevator 已废弃(实现质量不达标,代码保留在 src/_deprecated/),不再导出/发布
94
94
  // ---------------------------------------------------------------------------
95
95
  // 下拉菜单
96
96
  // ---------------------------------------------------------------------------
@@ -125,3 +125,12 @@ export { XSignatureSkia } from './XSignature/XSignatureSkia';
125
125
  // 上传(适配器模式:普通 multipart + MinIO 预签名直传)
126
126
  // ---------------------------------------------------------------------------
127
127
  export * from './XUpload';
128
+ // ---------------------------------------------------------------------------
129
+ // 主题色 + 全局主题控件
130
+ // ---------------------------------------------------------------------------
131
+ export { ThemeControls } from './XTheme/ThemeControls';
132
+ export { useXBrandStore, setXBrand, setXBrandByName, X_BRAND_PRESETS } from './XTheme/BrandColor';
133
+ // ---------------------------------------------------------------------------
134
+ // 视频预览
135
+ // ---------------------------------------------------------------------------
136
+ export { XVideoPreview } from './XVideoPreview';
package/dist/theme.d.ts CHANGED
@@ -150,14 +150,6 @@ export declare function setXThemeMode(mode: XThemeMode): void;
150
150
  export declare function getXThemeMode(): XThemeMode;
151
151
  /** 订阅当前模式(React 组件里用) */
152
152
  export declare function useXThemeMode(): XThemeMode;
153
- /**
154
- * 【核心 Hook】当前模式下的完整 token 集。
155
- *
156
- * - 返回值是模块级常量对象(lightTokens / darkTokens),
157
- * 引用稳定、不会触发无谓重渲染;模式切换时组件自然重渲染取到新对象;
158
- * - 解析规则:mode==='system' 时跟随 useColorScheme(),
159
- * 否则用强制值(未挂 Provider 也能工作,状态全局单例)。
160
- */
161
153
  export declare function useXTheme(): XTheme;
162
154
  /** 当前实际生效的明暗方案(isDark 便捷判断用) */
163
155
  export declare function useXThemeScheme(): XThemeScheme;
package/dist/theme.js CHANGED
@@ -95,7 +95,7 @@ export const darkTokens = {
95
95
  colorTextQuaternary: 'rgba(255, 255, 255, 0.25)',
96
96
  colorTextLightSolid: '#FFFFFF',
97
97
  colorBorder: 'rgba(255, 255, 255, 0.22)',
98
- colorSplit: 'rgba(255, 255, 255, 0.08)',
98
+ colorSplit: 'rgba(255, 255, 255, 0.15)',
99
99
  colorProgressTrack: 'rgba(255, 255, 255, 0.12)',
100
100
  colorBgContainer: '#212225',
101
101
  colorBgLayout: '#000000',
@@ -142,16 +142,32 @@ export function useXThemeMode() {
142
142
  /**
143
143
  * 【核心 Hook】当前模式下的完整 token 集。
144
144
  *
145
- * - 返回值是模块级常量对象(lightTokens / darkTokens),
146
- * 引用稳定、不会触发无谓重渲染;模式切换时组件自然重渲染取到新对象;
147
- * - 解析规则:mode==='system' 时跟随 useColorScheme()
148
- * 否则用强制值(未挂 Provider 也能工作,状态全局单例)。
145
+ * - 返回值按 (scheme × 品牌色) 缓存,**引用稳定**:同组合永远返回同一对象,
146
+ * 避免每次渲染新对象导致订阅组件级联重渲染(曾引发日历连点卡顿);
147
+ * - mode==='system' 时跟随 useColorScheme(),否则用强制值;
148
+ * - 注入运行时品牌色(zustand XBrand)覆盖 colorPrimary 等键。
149
149
  */
150
+ import { useXBrandStore } from './XTheme/BrandColor';
151
+ const themeCache = new Map();
150
152
  export function useXTheme() {
151
153
  const mode = useXThemeModeStore(s => s.mode);
152
154
  const systemScheme = useColorScheme();
153
155
  const scheme = mode === 'system' ? (systemScheme === 'dark' ? 'dark' : 'light') : mode;
154
- return scheme === 'dark' ? darkTokens : lightTokens;
156
+ const brand = useXBrandStore(s => s.brand);
157
+ const key = `${scheme}:${brand.name}`;
158
+ let cached = themeCache.get(key);
159
+ if (!cached) {
160
+ const base = scheme === 'dark' ? darkTokens : lightTokens;
161
+ cached = {
162
+ ...base,
163
+ colorPrimary: scheme === 'dark' ? brand.primaryDark : brand.primary,
164
+ colorPrimaryActive: scheme === 'dark' ? brand.primaryDark : brand.primary,
165
+ colorPrimaryBg: brand.primaryBg,
166
+ colorPrimaryDisabled: scheme === 'dark' ? 'rgba(255,255,255,0.18)' : 'rgba(32,128,240,0.28)',
167
+ };
168
+ themeCache.set(key, cached);
169
+ }
170
+ return cached;
155
171
  }
156
172
  /** 当前实际生效的明暗方案(isDark 便捷判断用) */
157
173
  export function useXThemeScheme() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-x-components",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Ant Design 风格的 React Native (Expo) 组件库:表单/弹层(TopView)/日历/Skia逐字签名/上传适配器(MinIO预签名)/轻量图表,内置暗黑模式与中英双语。",
5
5
  "main": "dist/index.js",
6
6
  "react-native": "dist/index.js",
@@ -40,7 +40,8 @@
40
40
  "react-native": ">=0.74.0",
41
41
  "react-native-reanimated": ">=3.17.0",
42
42
  "react-native-safe-area-context": ">=4.0.0",
43
- "react-native-svg": ">=14.0.0"
43
+ "react-native-svg": ">=14.0.0",
44
+ "react-native-gesture-handler": ">=2.0.0"
44
45
  },
45
46
  "peerDependenciesMeta": {
46
47
  "@shopify/react-native-skia": {
@@ -61,9 +62,6 @@
61
62
  "expo-image-picker": {
62
63
  "optional": true
63
64
  },
64
- "react-native-gesture-handler": {
65
- "optional": true
66
- },
67
65
  "react-native-linear-gradient": {
68
66
  "optional": true
69
67
  },
@@ -1,52 +0,0 @@
1
- /**
2
- * ============================================================================
3
- * XElevator —— 电梯楼层/字母导航列表(duxui Elevator 的 RN 原生重写版)
4
- * ============================================================================
5
- *
6
- * 【与 duxui Elevator 的关系】
7
- * duxui 用 ScrollView + Layout 手动累加每组高度算 scrollTop;
8
- * RN 端 SectionList 原生支持分组吸顶 + scrollToLocation,
9
- * 这里直接用 SectionList 重写:代码量减半、吸顶/定位更稳。
10
- *
11
- * 【右侧导航条】
12
- * - 触摸(按下/滑动)定位:容器拦截 responder 事件,用 locationY 与
13
- * 各标签 onLayout 记录的 y 区间比对,命中即 scrollToLocation;
14
- * - 滚动联动高亮:onViewableItemsChanged 取第一个可见项所属 section。
15
- *
16
- * 【API 对标 duxui】list -> sections({title, data}[])、onItemClick、
17
- * showNav、renderTop/Header/Footer/Empty;扩展 navLabel(导航条短标签,
18
- * 默认取 title 首字符)、keyExtractor。
19
- * ============================================================================
20
- */
21
- import React from 'react';
22
- import { StyleProp, ViewStyle } from 'react-native';
23
- export interface XElevatorSection<T = {
24
- name: string;
25
- }> {
26
- /** 分组标题(吸顶显示) */
27
- title: string;
28
- /** 右侧导航条短标签,默认取 title 首字符 */
29
- navLabel?: string;
30
- /** 数据 */
31
- data: T[];
32
- }
33
- export interface XElevatorProps<T> {
34
- sections: XElevatorSection<T>[];
35
- /** 自定义行渲染,默认显示 item.name */
36
- renderItem?: (item: T, section: XElevatorSection<T>, index: number) => React.ReactNode;
37
- keyExtractor?: (item: T, index: number) => string;
38
- onItemClick?: (item: T, section: XElevatorSection<T>) => void;
39
- /** 是否显示右侧导航条,默认 true */
40
- showNav?: boolean;
41
- /** 列表顶部(搜索框等) */
42
- renderTop?: () => React.ReactNode;
43
- /** 列表底部 */
44
- renderFooter?: () => React.ReactNode;
45
- /** 空态 */
46
- renderEmpty?: () => React.ReactNode;
47
- style?: StyleProp<ViewStyle>;
48
- }
49
- export declare function XElevator<T = {
50
- name: string;
51
- }>({ sections, renderItem, keyExtractor, onItemClick, showNav, renderTop, renderFooter, renderEmpty, style, }: XElevatorProps<T>): React.JSX.Element;
52
- export default XElevator;