@zmzai/theme 0.4.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmzai/theme",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "zmzai 全品牌设计系统 — Radix UI + framer-motion + Tailwind v4 + MiSans",
@@ -76,9 +76,9 @@
76
76
  "@radix-ui/react-select": "^2.1.0",
77
77
  "@radix-ui/react-tabs": "^1.1.0",
78
78
  "@radix-ui/react-tooltip": "^1.1.0",
79
+ "@tailwindcss/vite": "^4.0.0",
79
80
  "@types/react": "^19.0.0",
80
81
  "@types/react-dom": "^19.0.0",
81
- "@tailwindcss/vite": "^4.0.0",
82
82
  "@vitejs/plugin-react": "^4.3.0",
83
83
  "class-variance-authority": "^0.7.0",
84
84
  "clsx": "^2.1.0",
@@ -91,17 +91,18 @@
91
91
  "vite": "^5.4.0"
92
92
  },
93
93
  "dependencies": {
94
- "clsx": "^2.1.0",
95
- "tailwind-merge": "^2.5.0",
96
94
  "@radix-ui/react-dialog": "^1.1.0",
97
95
  "@radix-ui/react-dropdown-menu": "^2.1.0",
96
+ "@radix-ui/react-popover": "^1.1.23",
98
97
  "@radix-ui/react-select": "^2.1.0",
99
98
  "@radix-ui/react-tooltip": "^1.1.0",
100
99
  "class-variance-authority": "^0.7.0",
100
+ "clsx": "^2.1.0",
101
101
  "framer-motion": "^11.0.0",
102
+ "highlight.js": "^11.11.1",
102
103
  "react-markdown": "^10.1.0",
103
104
  "remark-gfm": "^4.0.1",
104
- "highlight.js": "^11.11.1"
105
+ "tailwind-merge": "^2.5.0"
105
106
  },
106
107
  "packageManager": "pnpm@10.34.5+sha512.a4ee05f2f73658255bd6a89859c065a45c28a57daefae2c893a168ee2b73168c37b91e83e57ea67654ad03f03031746430e8bce38e362e042605fb8abc80192e"
107
108
  }
@@ -85,3 +85,4 @@ export * from "./loader";
85
85
  export * from "./skeleton";
86
86
  export * from "./empty-state";
87
87
  export * from "./carousel";
88
+ export * from "./model-selector";
@@ -0,0 +1,282 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect, type ReactNode } from "react";
4
+ import * as Popover from "@radix-ui/react-popover";
5
+ import { cn } from "../../utils/cn";
6
+ import type {
7
+ ModelSelectorData,
8
+ ModelSelectorValue,
9
+ ModelSelectorModel,
10
+ ModelSelectorChannel,
11
+ } from "./types";
12
+ import "./model-selector.css";
13
+
14
+ export interface ModelSelectorProps {
15
+ /** 完整数据(featured + channels) */
16
+ data: ModelSelectorData;
17
+ /** 当前选中值 */
18
+ value: ModelSelectorValue;
19
+ /** 选中回调 */
20
+ onChange: (value: ModelSelectorValue) => void;
21
+ /** 触发器占位文案 */
22
+ placeholder?: string;
23
+ /** 是否显示搜索框 */
24
+ searchable?: boolean;
25
+ /** 自定义类名(作用于触发器) */
26
+ className?: string;
27
+ }
28
+
29
+ /** 在全部模型中查找当前选中模型,返回 {channel, model} 用于触发器显示。 */
30
+ function findSelectedModel(
31
+ data: ModelSelectorData,
32
+ value: ModelSelectorValue,
33
+ ): { model: ModelSelectorModel | null; channel: ModelSelectorChannel | null } {
34
+ // 先在 featured 中找
35
+ const featured = data.featured.find((m) => m.id === value.model);
36
+ if (featured) return { model: featured, channel: null };
37
+ // 再在 channels 中找
38
+ for (const channel of data.channels) {
39
+ const model = channel.models.find((m) => m.id === value.model);
40
+ if (model) return { model, channel };
41
+ }
42
+ return { model: null, channel: null };
43
+ }
44
+
45
+ /** 过滤数据(按关键词)。 */
46
+ function filterData(
47
+ data: ModelSelectorData,
48
+ query: string,
49
+ ): ModelSelectorData {
50
+ const q = query.trim().toLowerCase();
51
+ if (!q) return data;
52
+ const matchModel = (m: ModelSelectorModel) =>
53
+ m.name.toLowerCase().includes(q) ||
54
+ (m.description?.toLowerCase().includes(q) ?? false) ||
55
+ (m.channel?.toLowerCase().includes(q) ?? false);
56
+
57
+ const featured = data.featured.filter(matchModel);
58
+ const channels = data.channels
59
+ .map((ch) => ({ ...ch, models: ch.models.filter(matchModel) }))
60
+ .filter((ch) => ch.models.length > 0);
61
+ return { featured, channels };
62
+ }
63
+
64
+ export function ModelSelector({
65
+ data,
66
+ value,
67
+ onChange,
68
+ placeholder = "选择模型",
69
+ searchable = false,
70
+ className,
71
+ }: ModelSelectorProps) {
72
+ const [open, setOpen] = useState(false);
73
+ const [expandedChannels, setExpandedChannels] = useState<Set<string>>(new Set());
74
+ const [search, setSearch] = useState("");
75
+ const searchRef = useRef<HTMLInputElement>(null);
76
+
77
+ // 打开时聚焦搜索框
78
+ useEffect(() => {
79
+ if (open && searchable) {
80
+ // 等 Popover 渲染完再聚焦
81
+ const timer = setTimeout(() => searchRef.current?.focus(), 50);
82
+ return () => clearTimeout(timer);
83
+ }
84
+ }, [open, searchable]);
85
+
86
+ // 关闭时清理搜索
87
+ useEffect(() => {
88
+ if (!open) {
89
+ setSearch("");
90
+ }
91
+ }, [open]);
92
+
93
+ const { model: selectedModel, channel: selectedChannel } = findSelectedModel(data, value);
94
+ const filtered = filterData(data, search);
95
+
96
+ const hasResults = filtered.featured.length > 0 || filtered.channels.length > 0;
97
+
98
+ function toggleChannel(channelId: string) {
99
+ setExpandedChannels((prev) => {
100
+ const next = new Set(prev);
101
+ if (next.has(channelId)) next.delete(channelId);
102
+ else next.add(channelId);
103
+ return next;
104
+ });
105
+ }
106
+
107
+ function selectModel(model: ModelSelectorModel, channel?: ModelSelectorChannel) {
108
+ onChange({ channel: channel?.id ?? model.channel, model: model.id });
109
+ setOpen(false);
110
+ }
111
+
112
+ function isSelected(modelId: string): boolean {
113
+ return value.model === modelId;
114
+ }
115
+
116
+ const triggerLabel = selectedModel?.name ?? placeholder;
117
+
118
+ return (
119
+ <Popover.Root open={open} onOpenChange={setOpen}>
120
+ <Popover.Trigger asChild>
121
+ <button
122
+ type="button"
123
+ className={cn("ms-trigger", className)}
124
+ aria-label="选择模型"
125
+ >
126
+ <span className={cn(!selectedModel && "ms-trigger-placeholder")}>
127
+ {triggerLabel}
128
+ </span>
129
+ <svg
130
+ width="12"
131
+ height="12"
132
+ viewBox="0 0 12 12"
133
+ fill="none"
134
+ className={cn("ms-trigger-chevron", open && "ms-open")}
135
+ >
136
+ <path
137
+ d="M3 4.5L6 7.5L9 4.5"
138
+ stroke="currentColor"
139
+ strokeWidth="1.5"
140
+ strokeLinecap="round"
141
+ strokeLinejoin="round"
142
+ />
143
+ </svg>
144
+ </button>
145
+ </Popover.Trigger>
146
+
147
+ <Popover.Portal>
148
+ <Popover.Content
149
+ className="ms-popover"
150
+ sideOffset={4}
151
+ align="start"
152
+ onCloseAutoFocus={(e) => e.preventDefault()}
153
+ >
154
+ {searchable && (
155
+ <div className="ms-search">
156
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-ink-3" aria-hidden>
157
+ <circle cx="7" cy="7" r="4.5" />
158
+ <path d="M10.5 10.5L13.5 13.5" />
159
+ </svg>
160
+ <input
161
+ ref={searchRef}
162
+ className="ms-search-input"
163
+ placeholder="搜索模型…"
164
+ value={search}
165
+ onChange={(e) => setSearch(e.target.value)}
166
+ onKeyDown={(e) => {
167
+ if (e.key === "Escape") {
168
+ setSearch("");
169
+ setOpen(false);
170
+ }
171
+ }}
172
+ />
173
+ </div>
174
+ )}
175
+
176
+ {!hasResults ? (
177
+ <div className="ms-empty">没有找到匹配的模型</div>
178
+ ) : (
179
+ <div>
180
+ {/* Featured 区 */}
181
+ {filtered.featured.length > 0 && (
182
+ <div className="ms-featured">
183
+ {filtered.featured.map((model) => (
184
+ <button
185
+ key={model.id}
186
+ type="button"
187
+ className={cn("ms-featured-item", isSelected(model.id) && "ms-selected")}
188
+ onClick={() => selectModel(model)}
189
+ >
190
+ {model.icon && (
191
+ <span className="ms-featured-icon">{model.icon}</span>
192
+ )}
193
+ <span className="ms-featured-body">
194
+ <span className="ms-featured-name">{model.name}</span>
195
+ {model.description && (
196
+ <span className="ms-featured-desc">{model.description}</span>
197
+ )}
198
+ </span>
199
+ {isSelected(model.id) && (
200
+ <svg className="ms-featured-check" width="14" height="14" viewBox="0 0 14 14" fill="none">
201
+ <path d="M3 7L6 10L11 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
202
+ </svg>
203
+ )}
204
+ </button>
205
+ ))}
206
+ </div>
207
+ )}
208
+
209
+ {/* 分隔线 */}
210
+ {filtered.featured.length > 0 && filtered.channels.length > 0 && (
211
+ <div className="ms-separator" />
212
+ )}
213
+
214
+ {/* 渠道分组区 */}
215
+ {filtered.channels.map((channel) => {
216
+ const isOpen = expandedChannels.has(channel.id);
217
+ return (
218
+ <div key={channel.id} className="ms-channel">
219
+ <button
220
+ type="button"
221
+ className="ms-channel-trigger"
222
+ onClick={() => toggleChannel(channel.id)}
223
+ aria-expanded={isOpen}
224
+ >
225
+ {channel.icon && (
226
+ <span className="ms-channel-icon">{channel.icon}</span>
227
+ )}
228
+ <span>{channel.name}</span>
229
+ <span className="ms-channel-count">({channel.models.length})</span>
230
+ <svg
231
+ width="12"
232
+ height="12"
233
+ viewBox="0 0 12 12"
234
+ fill="none"
235
+ className={cn("ms-channel-chevron", isOpen && "ms-open")}
236
+ >
237
+ <path
238
+ d="M4.5 3L7.5 6L4.5 9"
239
+ stroke="currentColor"
240
+ strokeWidth="1.5"
241
+ strokeLinecap="round"
242
+ strokeLinejoin="round"
243
+ />
244
+ </svg>
245
+ </button>
246
+ {isOpen && (
247
+ <div className="ms-channel-models">
248
+ {channel.models.map((model) => (
249
+ <button
250
+ key={model.id}
251
+ type="button"
252
+ className={cn("ms-model-item", isSelected(model.id) && "ms-selected")}
253
+ onClick={() => selectModel(model, channel)}
254
+ >
255
+ {model.icon && (
256
+ <span className="ms-model-icon">{model.icon}</span>
257
+ )}
258
+ <span className="ms-model-name">{model.name}</span>
259
+ {model.meta && Object.values(model.meta).length > 0 && (
260
+ <span className="ms-model-meta">
261
+ {Object.values(model.meta).join(" · ")}
262
+ </span>
263
+ )}
264
+ {isSelected(model.id) && (
265
+ <svg className="ms-model-check" width="14" height="14" viewBox="0 0 14 14" fill="none">
266
+ <path d="M3 7L6 10L11 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
267
+ </svg>
268
+ )}
269
+ </button>
270
+ ))}
271
+ </div>
272
+ )}
273
+ </div>
274
+ );
275
+ })}
276
+ </div>
277
+ )}
278
+ </Popover.Content>
279
+ </Popover.Portal>
280
+ </Popover.Root>
281
+ );
282
+ }
@@ -0,0 +1,8 @@
1
+ export { ModelSelector } from "./ModelSelector";
2
+ export type { ModelSelectorProps } from "./ModelSelector";
3
+ export type {
4
+ ModelSelectorData,
5
+ ModelSelectorValue,
6
+ ModelSelectorModel,
7
+ ModelSelectorChannel,
8
+ } from "./types";
@@ -0,0 +1,276 @@
1
+ /* ModelSelector — 二级模型选择器样式 */
2
+
3
+ .ms-trigger {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ justify-content: space-between;
7
+ gap: 0.5rem;
8
+ height: 2.25rem;
9
+ padding: 0 0.75rem;
10
+ border-radius: var(--radius-sm, 2px);
11
+ border: 1px solid var(--color-line, oklch(0.85 0.02 80));
12
+ background: var(--color-bg, oklch(0.97 0.01 80));
13
+ font-size: 0.875rem;
14
+ font-weight: 500;
15
+ color: var(--color-ink, oklch(0.2 0.02 80));
16
+ cursor: pointer;
17
+ outline: none;
18
+ transition: border-color 0.15s ease;
19
+ min-width: 10rem;
20
+ }
21
+
22
+ .ms-trigger:hover {
23
+ border-color: oklch(0.4 0.02 80 / 0.4);
24
+ }
25
+
26
+ .ms-trigger:focus-visible {
27
+ border-color: var(--color-ink, oklch(0.2 0.02 80));
28
+ }
29
+
30
+ .ms-trigger-placeholder {
31
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
32
+ }
33
+
34
+ .ms-trigger-chevron {
35
+ transition: transform 0.2s ease;
36
+ }
37
+
38
+ .ms-trigger-chevron.ms-open {
39
+ transform: rotate(180deg);
40
+ }
41
+
42
+ /* Popover content */
43
+ .ms-popover {
44
+ z-index: 50;
45
+ min-width: 18rem;
46
+ max-height: 28rem;
47
+ overflow-y: auto;
48
+ border-radius: var(--radius-sm, 2px);
49
+ border: 1px solid var(--color-line, oklch(0.85 0.02 80));
50
+ background: var(--color-bg, oklch(0.97 0.01 80));
51
+ box-shadow: 0 10px 38px -10px oklch(0.2 0.02 80 / 0.25), 0 10px 20px -15px oklch(0.2 0.02 80 / 0.15);
52
+ padding: 0.25rem;
53
+ }
54
+
55
+ .ms-popover:focus {
56
+ outline: none;
57
+ }
58
+
59
+ /* Search */
60
+ .ms-search {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 0.5rem;
64
+ padding: 0.5rem 0.5rem 0.25rem;
65
+ border-bottom: 1px solid var(--color-line, oklch(0.85 0.02 80));
66
+ margin-bottom: 0.25rem;
67
+ }
68
+
69
+ .ms-search-input {
70
+ flex: 1;
71
+ height: 1.75rem;
72
+ padding: 0 0.5rem;
73
+ border: 1px solid var(--color-line, oklch(0.85 0.02 80));
74
+ border-radius: var(--radius-sm, 2px);
75
+ background: var(--color-bg, oklch(0.97 0.01 80));
76
+ font-size: 0.8125rem;
77
+ color: var(--color-ink, oklch(0.2 0.02 80));
78
+ outline: none;
79
+ }
80
+
81
+ .ms-search-input::placeholder {
82
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
83
+ }
84
+
85
+ .ms-search-input:focus {
86
+ border-color: var(--color-ink, oklch(0.2 0.02 80));
87
+ }
88
+
89
+ /* Featured section */
90
+ .ms-featured {
91
+ padding: 0.25rem 0;
92
+ }
93
+
94
+ .ms-featured-item {
95
+ display: flex;
96
+ align-items: flex-start;
97
+ gap: 0.625rem;
98
+ padding: 0.5rem 0.625rem;
99
+ border-radius: var(--radius-sm, 2px);
100
+ cursor: pointer;
101
+ transition: background 0.1s ease;
102
+ border: none;
103
+ background: none;
104
+ width: 100%;
105
+ text-align: left;
106
+ font: inherit;
107
+ color: inherit;
108
+ }
109
+
110
+ .ms-featured-item:hover,
111
+ .ms-featured-item:focus-visible {
112
+ background: var(--color-surface-2, oklch(0.93 0.01 80));
113
+ outline: none;
114
+ }
115
+
116
+ .ms-featured-item.ms-selected {
117
+ background: var(--color-surface-2, oklch(0.93 0.01 80));
118
+ }
119
+
120
+ .ms-featured-icon {
121
+ flex-shrink: 0;
122
+ margin-top: 0.125rem;
123
+ display: flex;
124
+ align-items: center;
125
+ }
126
+
127
+ .ms-featured-body {
128
+ flex: 1;
129
+ min-width: 0;
130
+ }
131
+
132
+ .ms-featured-name {
133
+ font-size: 0.875rem;
134
+ font-weight: 500;
135
+ color: var(--color-ink, oklch(0.2 0.02 80));
136
+ line-height: 1.3;
137
+ }
138
+
139
+ .ms-featured-desc {
140
+ margin-top: 0.125rem;
141
+ font-size: 0.75rem;
142
+ color: var(--color-ink-2, oklch(0.5 0.02 80));
143
+ line-height: 1.3;
144
+ }
145
+
146
+ .ms-featured-check {
147
+ flex-shrink: 0;
148
+ margin-top: 0.25rem;
149
+ color: var(--color-accent, oklch(0.45 0.18 25));
150
+ }
151
+
152
+ /* Separator */
153
+ .ms-separator {
154
+ margin: 0.375rem 0.5rem;
155
+ height: 1px;
156
+ background: var(--color-line, oklch(0.85 0.02 80));
157
+ }
158
+
159
+ /* Channel group */
160
+ .ms-channel {
161
+ padding: 0.125rem 0;
162
+ }
163
+
164
+ .ms-channel-trigger {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 0.5rem;
168
+ width: 100%;
169
+ padding: 0.4375rem 0.625rem;
170
+ border: none;
171
+ background: none;
172
+ cursor: pointer;
173
+ font: inherit;
174
+ font-size: 0.8125rem;
175
+ font-weight: 500;
176
+ color: var(--color-ink-2, oklch(0.5 0.02 80));
177
+ border-radius: var(--radius-sm, 2px);
178
+ transition: background 0.1s ease, color 0.1s ease;
179
+ }
180
+
181
+ .ms-channel-trigger:hover,
182
+ .ms-channel-trigger:focus-visible {
183
+ background: var(--color-surface-2, oklch(0.93 0.01 80));
184
+ color: var(--color-ink, oklch(0.2 0.02 80));
185
+ outline: none;
186
+ }
187
+
188
+ .ms-channel-icon {
189
+ flex-shrink: 0;
190
+ display: flex;
191
+ align-items: center;
192
+ }
193
+
194
+ .ms-channel-chevron {
195
+ margin-left: auto;
196
+ transition: transform 0.2s ease;
197
+ flex-shrink: 0;
198
+ }
199
+
200
+ .ms-channel-chevron.ms-open {
201
+ transform: rotate(90deg);
202
+ }
203
+
204
+ .ms-channel-count {
205
+ font-size: 0.6875rem;
206
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
207
+ font-weight: 400;
208
+ }
209
+
210
+ /* Channel models list */
211
+ .ms-channel-models {
212
+ padding: 0.125rem 0 0.25rem 0;
213
+ }
214
+
215
+ .ms-model-item {
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 0.5rem;
219
+ padding: 0.375rem 0.625rem 0.375rem 1.75rem;
220
+ border: none;
221
+ background: none;
222
+ cursor: pointer;
223
+ font: inherit;
224
+ font-size: 0.8125rem;
225
+ color: var(--color-ink-2, oklch(0.5 0.02 80));
226
+ border-radius: var(--radius-sm, 2px);
227
+ transition: background 0.1s ease;
228
+ width: 100%;
229
+ text-align: left;
230
+ }
231
+
232
+ .ms-model-item:hover,
233
+ .ms-model-item:focus-visible {
234
+ background: var(--color-surface-2, oklch(0.93 0.01 80));
235
+ color: var(--color-ink, oklch(0.2 0.02 80));
236
+ outline: none;
237
+ }
238
+
239
+ .ms-model-item.ms-selected {
240
+ color: var(--color-ink, oklch(0.2 0.02 80));
241
+ font-weight: 500;
242
+ }
243
+
244
+ .ms-model-icon {
245
+ flex-shrink: 0;
246
+ display: flex;
247
+ align-items: center;
248
+ }
249
+
250
+ .ms-model-name {
251
+ flex: 1;
252
+ min-width: 0;
253
+ overflow: hidden;
254
+ text-overflow: ellipsis;
255
+ white-space: nowrap;
256
+ }
257
+
258
+ .ms-model-check {
259
+ flex-shrink: 0;
260
+ color: var(--color-accent, oklch(0.45 0.18 25));
261
+ }
262
+
263
+ .ms-model-meta {
264
+ font-size: 0.6875rem;
265
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
266
+ font-weight: 400;
267
+ flex-shrink: 0;
268
+ }
269
+
270
+ /* Empty state */
271
+ .ms-empty {
272
+ padding: 1.5rem 1rem;
273
+ text-align: center;
274
+ font-size: 0.8125rem;
275
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
276
+ }
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** 单个模型条目。 */
4
+ export interface ModelSelectorModel {
5
+ /** 唯一标识,如 "gpt-5.6-terra" */
6
+ id: string;
7
+ /** 显示名 */
8
+ name: string;
9
+ /** 一句话描述(用于 featured 区) */
10
+ description?: string;
11
+ /** 供应商图标(消费方传入) */
12
+ icon?: ReactNode;
13
+ /** 所属渠道名(用于分组显示) */
14
+ channel?: string;
15
+ /** 附加信息,如价格、上下文等 */
16
+ meta?: Record<string, string>;
17
+ }
18
+
19
+ /** 渠道分组。 */
20
+ export interface ModelSelectorChannel {
21
+ /** 渠道 ID */
22
+ id: string;
23
+ /** 渠道名,如 "Azure OpenAI" */
24
+ name: string;
25
+ /** 渠道图标(消费方传入) */
26
+ icon?: ReactNode;
27
+ /** 该渠道下的模型 */
28
+ models: ModelSelectorModel[];
29
+ }
30
+
31
+ /** ModelSelector 完整数据。 */
32
+ export interface ModelSelectorData {
33
+ /** 顶部推荐模型(带描述,直接展示不折叠) */
34
+ featured: ModelSelectorModel[];
35
+ /** 渠道分组列表 */
36
+ channels: ModelSelectorChannel[];
37
+ }
38
+
39
+ /** 选中值。 */
40
+ export interface ModelSelectorValue {
41
+ channel?: string;
42
+ model: string;
43
+ }