ideaco 1.1.5
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/.dockerignore +33 -0
- package/.nvmrc +1 -0
- package/ARCHITECTURE.md +394 -0
- package/Dockerfile +50 -0
- package/LICENSE +29 -0
- package/README.md +206 -0
- package/bin/i18n.js +46 -0
- package/bin/ideaco.js +494 -0
- package/deploy.sh +15 -0
- package/docker-compose.yml +30 -0
- package/electron/main.cjs +986 -0
- package/electron/preload.cjs +14 -0
- package/electron/web-backends.cjs +854 -0
- package/jsconfig.json +8 -0
- package/next.config.mjs +34 -0
- package/package.json +134 -0
- package/postcss.config.mjs +6 -0
- package/public/demo/dashboard.png +0 -0
- package/public/demo/employee.png +0 -0
- package/public/demo/messages.png +0 -0
- package/public/demo/office.png +0 -0
- package/public/demo/requirement.png +0 -0
- package/public/logo.jpeg +0 -0
- package/public/logo.png +0 -0
- package/scripts/prepare-electron.js +67 -0
- package/scripts/release.js +76 -0
- package/src/app/api/agents/[agentId]/chat/route.js +70 -0
- package/src/app/api/agents/[agentId]/conversations/route.js +35 -0
- package/src/app/api/agents/[agentId]/route.js +106 -0
- package/src/app/api/avatar/route.js +104 -0
- package/src/app/api/browse-dir/route.js +44 -0
- package/src/app/api/chat/route.js +265 -0
- package/src/app/api/company/factory-reset/route.js +43 -0
- package/src/app/api/company/route.js +82 -0
- package/src/app/api/departments/[deptId]/agents/[agentId]/dismiss/route.js +19 -0
- package/src/app/api/departments/route.js +92 -0
- package/src/app/api/group-chat-loop/events/route.js +70 -0
- package/src/app/api/group-chat-loop/route.js +94 -0
- package/src/app/api/mailbox/route.js +100 -0
- package/src/app/api/messages/route.js +14 -0
- package/src/app/api/providers/[id]/configure/route.js +21 -0
- package/src/app/api/providers/[id]/refresh-cookie/route.js +38 -0
- package/src/app/api/providers/[id]/test-cookie/route.js +28 -0
- package/src/app/api/providers/route.js +11 -0
- package/src/app/api/requirements/route.js +242 -0
- package/src/app/api/secretary/route.js +65 -0
- package/src/app/api/system/cli-backends/route.js +91 -0
- package/src/app/api/system/cron/route.js +110 -0
- package/src/app/api/system/knowledge/route.js +104 -0
- package/src/app/api/system/plugins/route.js +40 -0
- package/src/app/api/system/skills/route.js +46 -0
- package/src/app/api/system/status/route.js +46 -0
- package/src/app/api/talent-market/[profileId]/recall/route.js +22 -0
- package/src/app/api/talent-market/[profileId]/route.js +17 -0
- package/src/app/api/talent-market/route.js +26 -0
- package/src/app/api/teams/route.js +773 -0
- package/src/app/api/ws-files/[departmentId]/file/route.js +27 -0
- package/src/app/api/ws-files/[departmentId]/files/route.js +22 -0
- package/src/app/globals.css +130 -0
- package/src/app/layout.jsx +40 -0
- package/src/app/page.jsx +97 -0
- package/src/components/AgentChatModal.jsx +164 -0
- package/src/components/AgentDetailModal.jsx +425 -0
- package/src/components/AgentSpyModal.jsx +481 -0
- package/src/components/AvatarGrid.jsx +29 -0
- package/src/components/BossProfileModal.jsx +162 -0
- package/src/components/CachedAvatar.jsx +77 -0
- package/src/components/ChatPanel.jsx +219 -0
- package/src/components/ChatShared.jsx +255 -0
- package/src/components/DepartmentDetail.jsx +842 -0
- package/src/components/DepartmentView.jsx +367 -0
- package/src/components/FileReference.jsx +260 -0
- package/src/components/FilesView.jsx +465 -0
- package/src/components/GroupChatView.jsx +799 -0
- package/src/components/Mailbox.jsx +926 -0
- package/src/components/MessagesView.jsx +112 -0
- package/src/components/OnboardingGuide.jsx +209 -0
- package/src/components/OrgTree.jsx +151 -0
- package/src/components/Overview.jsx +391 -0
- package/src/components/PixelOffice.jsx +2281 -0
- package/src/components/ProviderGrid.jsx +551 -0
- package/src/components/ProvidersBoard.jsx +16 -0
- package/src/components/RequirementDetail.jsx +1279 -0
- package/src/components/RequirementsBoard.jsx +187 -0
- package/src/components/SecretarySettings.jsx +295 -0
- package/src/components/SetupWizard.jsx +388 -0
- package/src/components/Sidebar.jsx +169 -0
- package/src/components/SystemMonitor.jsx +808 -0
- package/src/components/TalentMarket.jsx +183 -0
- package/src/components/TeamDetail.jsx +697 -0
- package/src/core/agent/base-agent.js +104 -0
- package/src/core/agent/chat-store.js +602 -0
- package/src/core/agent/cli-agent/backends/claude-code/README.md +52 -0
- package/src/core/agent/cli-agent/backends/claude-code/config.js +27 -0
- package/src/core/agent/cli-agent/backends/codebuddy/README.md +236 -0
- package/src/core/agent/cli-agent/backends/codebuddy/config.js +27 -0
- package/src/core/agent/cli-agent/backends/codex/README.md +51 -0
- package/src/core/agent/cli-agent/backends/codex/config.js +27 -0
- package/src/core/agent/cli-agent/backends/index.js +27 -0
- package/src/core/agent/cli-agent/backends/registry.js +580 -0
- package/src/core/agent/cli-agent/index.js +154 -0
- package/src/core/agent/index.js +60 -0
- package/src/core/agent/llm-agent/client.js +320 -0
- package/src/core/agent/llm-agent/index.js +97 -0
- package/src/core/agent/message-bus.js +211 -0
- package/src/core/agent/session.js +608 -0
- package/src/core/agent/tools.js +596 -0
- package/src/core/agent/web-agent/backends/base-backend.js +180 -0
- package/src/core/agent/web-agent/backends/chatgpt/client.js +146 -0
- package/src/core/agent/web-agent/backends/chatgpt/config.js +148 -0
- package/src/core/agent/web-agent/backends/chatgpt/dom-scripts.js +303 -0
- package/src/core/agent/web-agent/backends/index.js +91 -0
- package/src/core/agent/web-agent/index.js +278 -0
- package/src/core/agent/web-agent/web-client.js +407 -0
- package/src/core/employee/base-employee.js +1088 -0
- package/src/core/employee/index.js +35 -0
- package/src/core/employee/knowledge.js +327 -0
- package/src/core/employee/lifecycle.js +990 -0
- package/src/core/employee/memory/index.js +642 -0
- package/src/core/employee/memory/store.js +143 -0
- package/src/core/employee/performance.js +224 -0
- package/src/core/employee/secretary.js +625 -0
- package/src/core/employee/skills.js +398 -0
- package/src/core/index.js +38 -0
- package/src/core/organization/company.js +2600 -0
- package/src/core/organization/department.js +737 -0
- package/src/core/organization/group-chat-loop.js +264 -0
- package/src/core/organization/index.js +8 -0
- package/src/core/organization/persistence.js +111 -0
- package/src/core/organization/team.js +267 -0
- package/src/core/organization/workforce/hr.js +377 -0
- package/src/core/organization/workforce/providers.js +468 -0
- package/src/core/organization/workforce/role-archetypes.js +805 -0
- package/src/core/organization/workforce/talent-market.js +205 -0
- package/src/core/prompts.js +532 -0
- package/src/core/requirement.js +1789 -0
- package/src/core/system/audit.js +483 -0
- package/src/core/system/cron.js +449 -0
- package/src/core/system/index.js +7 -0
- package/src/core/system/plugin.js +2183 -0
- package/src/core/utils/json-parse.js +188 -0
- package/src/core/workspace.js +239 -0
- package/src/lib/api-i18n.js +211 -0
- package/src/lib/avatar.js +268 -0
- package/src/lib/client-store.js +1025 -0
- package/src/lib/config-validator.js +483 -0
- package/src/lib/format-time.js +22 -0
- package/src/lib/hooks.js +414 -0
- package/src/lib/i18n.js +134 -0
- package/src/lib/paths.js +23 -0
- package/src/lib/store.js +72 -0
- package/src/locales/de.js +393 -0
- package/src/locales/en.js +1054 -0
- package/src/locales/es.js +393 -0
- package/src/locales/fr.js +393 -0
- package/src/locales/ja.js +501 -0
- package/src/locales/ko.js +513 -0
- package/src/locales/zh.js +828 -0
- package/tailwind.config.mjs +11 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import { useState, useEffect, useRef } from 'react';
|
|
5
|
+
import { useStore } from '@/lib/client-store';
|
|
6
|
+
import { useI18n } from '@/lib/i18n';
|
|
7
|
+
import AgentDetailModal from './AgentDetailModal';
|
|
8
|
+
import ReactMarkdown from 'react-markdown';
|
|
9
|
+
import remarkGfm from 'remark-gfm';
|
|
10
|
+
import CachedAvatar from './CachedAvatar';
|
|
11
|
+
|
|
12
|
+
// 复用 Mailbox 的 Markdown 渲染组件映射
|
|
13
|
+
const chatMarkdownComponents = {
|
|
14
|
+
p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
|
|
15
|
+
ul: ({ children }) => <ul className="list-disc list-inside mb-1 space-y-0.5">{children}</ul>,
|
|
16
|
+
ol: ({ children }) => <ol className="list-decimal list-inside mb-1 space-y-0.5">{children}</ol>,
|
|
17
|
+
li: ({ children }) => <li className="text-sm">{children}</li>,
|
|
18
|
+
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
|
|
19
|
+
em: ({ children }) => <em className="italic">{children}</em>,
|
|
20
|
+
code: ({ inline, children }) => {
|
|
21
|
+
if (inline) {
|
|
22
|
+
return <code className="bg-white/10 px-1 py-0.5 rounded text-xs font-mono">{children}</code>;
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
<pre className="bg-black/30 rounded-lg p-2 my-1 overflow-x-auto">
|
|
26
|
+
<code className="text-xs font-mono">{children}</code>
|
|
27
|
+
</pre>
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
a: ({ href, children }) => (
|
|
31
|
+
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-400 underline hover:text-blue-300">
|
|
32
|
+
{children}
|
|
33
|
+
</a>
|
|
34
|
+
),
|
|
35
|
+
h1: ({ children }) => <h1 className="text-base font-bold mb-1">{children}</h1>,
|
|
36
|
+
h2: ({ children }) => <h2 className="text-sm font-bold mb-1">{children}</h2>,
|
|
37
|
+
h3: ({ children }) => <h3 className="text-sm font-semibold mb-0.5">{children}</h3>,
|
|
38
|
+
blockquote: ({ children }) => (
|
|
39
|
+
<blockquote className="border-l-2 border-white/30 pl-2 my-1 text-[var(--muted)]">{children}</blockquote>
|
|
40
|
+
),
|
|
41
|
+
hr: () => <hr className="my-2 border-white/10" />,
|
|
42
|
+
table: ({ children }) => (
|
|
43
|
+
<div className="overflow-x-auto my-1">
|
|
44
|
+
<table className="text-xs border-collapse">{children}</table>
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
th: ({ children }) => <th className="border border-white/20 px-2 py-1 bg-white/5 font-semibold">{children}</th>,
|
|
48
|
+
td: ({ children }) => <td className="border border-white/20 px-2 py-1">{children}</td>,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// 清理消息内容中的内部标签
|
|
52
|
+
function cleanMessageContent(content) {
|
|
53
|
+
if (!content || typeof content !== 'string') return content;
|
|
54
|
+
let cleaned = content.replace(/<[||]DSML[||][^>]*>[\s\S]*/g, '').trim();
|
|
55
|
+
cleaned = cleaned.replace(/<\|DSML\|[^>]*>[\s\S]*/g, '').trim();
|
|
56
|
+
cleaned = cleaned.replace(/<\|(?:im_start|im_end|endoftext)\|>/g, '').trim();
|
|
57
|
+
return cleaned || content;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 渲染 @[id] 或 @Name mention 高亮
|
|
62
|
+
* 同时兼容新格式 @[agentId] 和旧格式 @AgentName
|
|
63
|
+
*/
|
|
64
|
+
function renderMentions(text, agentMap, onClickMention) {
|
|
65
|
+
if (!text || typeof text !== 'string') return null;
|
|
66
|
+
|
|
67
|
+
const nameToId = {};
|
|
68
|
+
if (agentMap) {
|
|
69
|
+
for (const [id, name] of Object.entries(agentMap)) {
|
|
70
|
+
nameToId[name] = id;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hasNewFormat = /@\[[^\]]+\]/.test(text);
|
|
75
|
+
const names = Object.keys(nameToId).sort((a, b) => b.length - a.length);
|
|
76
|
+
const hasOldFormat = names.length > 0 && names.some(n => text.includes(`@${n}`));
|
|
77
|
+
|
|
78
|
+
if (!hasNewFormat && !hasOldFormat) return null;
|
|
79
|
+
|
|
80
|
+
const regexParts = ['(@\\[[^\\]]+\\])'];
|
|
81
|
+
if (names.length > 0) {
|
|
82
|
+
const escapedNames = names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
83
|
+
regexParts.push(`(@(?:${escapedNames.join('|')}))`);
|
|
84
|
+
}
|
|
85
|
+
const regex = new RegExp(regexParts.join('|'), 'g');
|
|
86
|
+
const parts = text.split(regex).filter(p => p !== undefined);
|
|
87
|
+
|
|
88
|
+
if (parts.length <= 1) return null;
|
|
89
|
+
|
|
90
|
+
const renderTag = (key, displayName, agentId) => (
|
|
91
|
+
<span
|
|
92
|
+
key={key}
|
|
93
|
+
className={`inline-flex items-center bg-blue-500/30 text-blue-200 px-1.5 py-0.5 rounded text-xs font-semibold mx-0.5 border border-blue-500/20 ${onClickMention ? 'cursor-pointer hover:bg-blue-500/40 transition-colors' : ''}`}
|
|
94
|
+
onClick={() => agentId && onClickMention?.(agentId)}
|
|
95
|
+
>
|
|
96
|
+
@{displayName}
|
|
97
|
+
</span>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return parts.map((part, i) => {
|
|
101
|
+
const newMatch = part.match(/^@\[([^\]]+)\]$/);
|
|
102
|
+
if (newMatch) {
|
|
103
|
+
const id = newMatch[1];
|
|
104
|
+
const name = agentMap?.[id] || id;
|
|
105
|
+
return renderTag(i, name, id);
|
|
106
|
+
}
|
|
107
|
+
const oldMatch = part.match(/^@(.+)$/);
|
|
108
|
+
if (oldMatch && nameToId[oldMatch[1]]) {
|
|
109
|
+
const name = oldMatch[1];
|
|
110
|
+
const id = nameToId[name];
|
|
111
|
+
return renderTag(i, name, id);
|
|
112
|
+
}
|
|
113
|
+
return part;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 消息分组:将同一发送者连续的短消息合并到一组
|
|
119
|
+
*/
|
|
120
|
+
function groupConsecutiveMessages(messages, getSenderId) {
|
|
121
|
+
if (!messages?.length) return [];
|
|
122
|
+
const groups = [];
|
|
123
|
+
let currentGroup = null;
|
|
124
|
+
|
|
125
|
+
for (const msg of messages) {
|
|
126
|
+
const senderId = getSenderId(msg);
|
|
127
|
+
const isShort = (msg.content?.length || 0) <= 120;
|
|
128
|
+
const timeDiff = currentGroup
|
|
129
|
+
? Math.abs(new Date(msg.time) - new Date(currentGroup.messages[currentGroup.messages.length - 1].time)) / 1000
|
|
130
|
+
: Infinity;
|
|
131
|
+
|
|
132
|
+
if (currentGroup && currentGroup.senderId === senderId && isShort && timeDiff <= 60 && currentGroup.isShort) {
|
|
133
|
+
currentGroup.messages.push(msg);
|
|
134
|
+
} else {
|
|
135
|
+
currentGroup = { senderId, messages: [msg], isShort };
|
|
136
|
+
groups.push(currentGroup);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return groups;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* AgentSpyModal — 🕵️ 偷窥某员工的IM界面
|
|
144
|
+
* 左边:同事头像列表(会话列表)
|
|
145
|
+
* 右边:聊天窗口
|
|
146
|
+
*/
|
|
147
|
+
export default function AgentSpyModal({ agentId, agentName, agentAvatar, onClose }) {
|
|
148
|
+
const { t } = useI18n();
|
|
149
|
+
const { fetchAgentConversations, fetchAgentConversationHistory } = useStore();
|
|
150
|
+
const [conversations, setConversations] = useState([]);
|
|
151
|
+
const [selectedConv, setSelectedConv] = useState(null);
|
|
152
|
+
const [messages, setMessages] = useState([]);
|
|
153
|
+
const [sessionParticipants, setSessionParticipants] = useState([]);
|
|
154
|
+
const [loading, setLoading] = useState(true);
|
|
155
|
+
const [msgLoading, setMsgLoading] = useState(false);
|
|
156
|
+
const [selectedAgentId, setSelectedAgentId] = useState(null); // 点击头像/消息弹出员工详情
|
|
157
|
+
const messagesEndRef = useRef(null);
|
|
158
|
+
|
|
159
|
+
// 构建 agentId -> agentName 映射(用于 @[id] 渲染)
|
|
160
|
+
const { company } = useStore();
|
|
161
|
+
const agentMap = {};
|
|
162
|
+
if (company?.departments) {
|
|
163
|
+
for (const dept of company.departments) {
|
|
164
|
+
for (const agent of (dept.members || dept.agents || [])) {
|
|
165
|
+
agentMap[agent.id] = agent.name;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 加载会话列表
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
setLoading(true);
|
|
173
|
+
fetchAgentConversations(agentId).then(data => {
|
|
174
|
+
setConversations(data);
|
|
175
|
+
// 自动选中第一个会话
|
|
176
|
+
if (data.length > 0) {
|
|
177
|
+
setSelectedConv(data[0]);
|
|
178
|
+
}
|
|
179
|
+
setLoading(false);
|
|
180
|
+
}).catch(() => setLoading(false));
|
|
181
|
+
}, [agentId]);
|
|
182
|
+
|
|
183
|
+
// 选中会话后加载消息
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (selectedConv) {
|
|
186
|
+
setMsgLoading(true);
|
|
187
|
+
fetchAgentConversationHistory(agentId, selectedConv.sessionId).then(result => {
|
|
188
|
+
setMessages(result.messages || []);
|
|
189
|
+
setSessionParticipants(result.participants || []);
|
|
190
|
+
setMsgLoading(false);
|
|
191
|
+
}).catch(() => setMsgLoading(false));
|
|
192
|
+
}
|
|
193
|
+
}, [selectedConv]);
|
|
194
|
+
|
|
195
|
+
// 自动滚动到底部
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
198
|
+
}, [messages]);
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[70] !m-0" onClick={onClose}>
|
|
202
|
+
<div
|
|
203
|
+
className="bg-[var(--card)] border border-[var(--border)] rounded-2xl max-w-3xl w-full mx-4 h-[75vh] flex flex-col overflow-hidden shadow-2xl"
|
|
204
|
+
onClick={e => e.stopPropagation()}
|
|
205
|
+
>
|
|
206
|
+
{/* Header */}
|
|
207
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border)] shrink-0 bg-[var(--card)]">
|
|
208
|
+
<div className="text-lg">🕵️</div>
|
|
209
|
+
<div className="flex-1 min-w-0">
|
|
210
|
+
<div className="text-sm font-semibold flex items-center gap-2">
|
|
211
|
+
{t('agent.spyTitle', { name: agentName })}
|
|
212
|
+
<span className="text-[10px] text-[var(--muted)] font-normal bg-purple-500/10 text-purple-400 px-1.5 py-0.5 rounded-full">
|
|
213
|
+
{t('agent.spyMode')}
|
|
214
|
+
</span>
|
|
215
|
+
</div>
|
|
216
|
+
<div className="text-[10px] text-[var(--muted)]">{t('agent.spyHint')}</div>
|
|
217
|
+
</div>
|
|
218
|
+
<button
|
|
219
|
+
onClick={onClose}
|
|
220
|
+
className="text-[var(--muted)] hover:text-white text-xl w-7 h-7 flex items-center justify-center rounded-lg hover:bg-white/10 transition-all"
|
|
221
|
+
>
|
|
222
|
+
✕
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{/* Body: 左边联系人列表 + 右边聊天区 */}
|
|
227
|
+
<div className="flex-1 flex overflow-hidden">
|
|
228
|
+
{/* 左侧:联系人列表 */}
|
|
229
|
+
<div className="w-[200px] shrink-0 border-r border-[var(--border)] overflow-auto bg-[var(--background)]/50">
|
|
230
|
+
{loading ? (
|
|
231
|
+
<div className="flex items-center justify-center h-full">
|
|
232
|
+
<div className="text-2xl animate-pulse">🔍</div>
|
|
233
|
+
</div>
|
|
234
|
+
) : conversations.length === 0 ? (
|
|
235
|
+
<div className="flex flex-col items-center justify-center h-full px-4 text-center">
|
|
236
|
+
<div className="text-3xl mb-2">🤫</div>
|
|
237
|
+
<p className="text-xs text-[var(--muted)]">{t('agent.noConversations')}</p>
|
|
238
|
+
<p className="text-[10px] text-[var(--muted)] mt-1">{t('agent.noConversationsHint')}</p>
|
|
239
|
+
</div>
|
|
240
|
+
) : (
|
|
241
|
+
<div className="py-1">
|
|
242
|
+
{conversations.map(conv => {
|
|
243
|
+
const isActive = selectedConv?.sessionId === conv.sessionId;
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
key={conv.sessionId}
|
|
247
|
+
onClick={() => setSelectedConv(conv)}
|
|
248
|
+
className={`flex items-center gap-2.5 px-3 py-2.5 cursor-pointer transition-all ${
|
|
249
|
+
isActive
|
|
250
|
+
? 'bg-[var(--accent)]/10 border-l-2 border-[var(--accent)]'
|
|
251
|
+
: 'hover:bg-white/5 border-l-2 border-transparent'
|
|
252
|
+
}`}
|
|
253
|
+
>
|
|
254
|
+
{/* 头像 */}
|
|
255
|
+
{conv.peerAvatar ? (
|
|
256
|
+
<img
|
|
257
|
+
src={conv.peerAvatar}
|
|
258
|
+
alt=""
|
|
259
|
+
className={`w-9 h-9 rounded-full bg-[var(--border)] shrink-0 ${isActive ? 'ring-2 ring-[var(--accent)]/50' : ''}`}
|
|
260
|
+
/>
|
|
261
|
+
) : (
|
|
262
|
+
<div className={`w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-sm shrink-0 ${isActive ? 'ring-2 ring-[var(--accent)]/50' : ''}`}>
|
|
263
|
+
{conv.type === 'boss-agent' ? '👤' : '🤖'}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
{/* 名字和简介 */}
|
|
267
|
+
<div className="flex-1 min-w-0">
|
|
268
|
+
<div className="text-xs font-medium truncate">{conv.peerName}</div>
|
|
269
|
+
{conv.peerRole && (
|
|
270
|
+
<div className="text-[10px] text-[var(--muted)] truncate">{conv.peerRole}</div>
|
|
271
|
+
)}
|
|
272
|
+
{conv.lastMessage && (
|
|
273
|
+
<div className="text-[10px] text-[var(--muted)] truncate mt-0.5 italic">{conv.lastMessage}</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
{/* 消息数气泡 */}
|
|
277
|
+
{conv.totalMessages > 0 && (
|
|
278
|
+
<span className="text-[9px] bg-white/10 text-[var(--muted)] px-1.5 py-0.5 rounded-full shrink-0">
|
|
279
|
+
{conv.totalMessages}
|
|
280
|
+
</span>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
})}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{/* 右侧:聊天窗口 */}
|
|
290
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
291
|
+
{!selectedConv ? (
|
|
292
|
+
/* 未选中会话 */
|
|
293
|
+
<div className="flex-1 flex flex-col items-center justify-center text-center">
|
|
294
|
+
<div className="text-5xl mb-3 opacity-30">🕵️</div>
|
|
295
|
+
<p className="text-sm text-[var(--muted)]">{t('agent.spySelectHint')}</p>
|
|
296
|
+
</div>
|
|
297
|
+
) : (
|
|
298
|
+
<>
|
|
299
|
+
{/* 聊天对象信息 header */}
|
|
300
|
+
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--border)] shrink-0 bg-[var(--background)]/30">
|
|
301
|
+
{selectedConv.peerAvatar ? (
|
|
302
|
+
<CachedAvatar src={selectedConv.peerAvatar} alt="" className="w-8 h-8 rounded-full bg-[var(--border)]" />
|
|
303
|
+
) : (
|
|
304
|
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-sm">
|
|
305
|
+
{selectedConv.type === 'boss-agent' ? '👤' : '🤖'}
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
<div className="flex-1 min-w-0">
|
|
309
|
+
<div className="text-sm font-medium flex items-center gap-2">
|
|
310
|
+
<span className="truncate">{selectedConv.peerName}</span>
|
|
311
|
+
</div>
|
|
312
|
+
<div className="flex items-center gap-2">
|
|
313
|
+
{selectedConv.peerRole && (
|
|
314
|
+
<span className="text-[10px] text-[var(--muted)] bg-white/5 px-1.5 py-0.5 rounded">{selectedConv.peerRole}</span>
|
|
315
|
+
)}
|
|
316
|
+
{selectedConv.peerDepartment && (
|
|
317
|
+
<span className="text-[10px] text-[var(--muted)]">· {selectedConv.peerDepartment}</span>
|
|
318
|
+
)}
|
|
319
|
+
<span className="text-[10px] text-[var(--muted)]">· {selectedConv.totalMessages} {t('agent.spyMsgCount')}</span>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
{/* 消息列表 */}
|
|
325
|
+
<div className="flex-1 overflow-auto px-4 py-3 space-y-3">
|
|
326
|
+
{msgLoading ? (
|
|
327
|
+
<div className="flex items-center justify-center h-full">
|
|
328
|
+
<div className="text-center">
|
|
329
|
+
<div className="text-2xl animate-pulse">🔍</div>
|
|
330
|
+
<p className="text-xs text-[var(--muted)] mt-2">{t('agent.spyLoading')}</p>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
) : messages.length === 0 ? (
|
|
334
|
+
<div className="flex items-center justify-center h-full">
|
|
335
|
+
<div className="text-center">
|
|
336
|
+
<div className="text-3xl mb-2 opacity-30">💬</div>
|
|
337
|
+
<p className="text-sm text-[var(--muted)]">{t('agent.noConversationMessages')}</p>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
) : (
|
|
341
|
+
groupConsecutiveMessages(messages, m => {
|
|
342
|
+
// 判断消息发送者ID
|
|
343
|
+
if (m.fromAgentId) return m.fromAgentId;
|
|
344
|
+
// 旧数据 fallback: boss-agent 会话根据 role 判断
|
|
345
|
+
if (selectedConv.type === 'boss-agent') {
|
|
346
|
+
return m.role === 'boss' ? 'boss' : agentId;
|
|
347
|
+
}
|
|
348
|
+
// 旧数据 fallback: agent-agent 会话,如果消息内容 @了对方,说明是目标员工发的
|
|
349
|
+
const peerId = selectedConv.peerId;
|
|
350
|
+
if (peerId && m.content) {
|
|
351
|
+
const peerName = agentMap[peerId];
|
|
352
|
+
if (peerName && (m.content.includes(`@${peerName}`) || m.content.includes(`@[${peerId}]`))) {
|
|
353
|
+
return agentId; // 目标员工 @ 了对方 → 是目标员工发的
|
|
354
|
+
}
|
|
355
|
+
const agentName_ = agentMap[agentId];
|
|
356
|
+
if (agentName_ && (m.content.includes(`@${agentName_}`) || m.content.includes(`@[${agentId}]`))) {
|
|
357
|
+
return peerId; // 对方 @ 了目标员工 → 是对方发的
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// 最终 fallback: 交替分配
|
|
361
|
+
return '__unknown__';
|
|
362
|
+
}).map((group, gi) => {
|
|
363
|
+
const firstMsg = group.messages[0];
|
|
364
|
+
// 判断消息是否由目标员工发出
|
|
365
|
+
const isFromAgent = firstMsg.fromAgentId === agentId
|
|
366
|
+
|| (selectedConv.type === 'boss-agent' && firstMsg.role !== 'boss')
|
|
367
|
+
|| (!firstMsg.fromAgentId && selectedConv.type !== 'boss-agent' && (() => {
|
|
368
|
+
// 旧数据 fallback 判断
|
|
369
|
+
const peerId = selectedConv.peerId;
|
|
370
|
+
const peerName = agentMap[peerId];
|
|
371
|
+
if (peerName && firstMsg.content && (firstMsg.content.includes(`@${peerName}`) || firstMsg.content.includes(`@[${peerId}]`))) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
})());
|
|
376
|
+
const isBoss = firstMsg.role === 'boss';
|
|
377
|
+
const isRight = isFromAgent && !isBoss;
|
|
378
|
+
const isMerged = group.messages.length > 1;
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<div key={`group-${gi}`} className={`flex gap-2 ${isRight ? 'flex-row-reverse' : ''}`}>
|
|
382
|
+
{/* Avatar */}
|
|
383
|
+
{isRight ? (
|
|
384
|
+
agentAvatar ? (
|
|
385
|
+
<CachedAvatar src={agentAvatar} alt="" className="w-7 h-7 rounded-full bg-[var(--border)] shrink-0 mt-0.5 cursor-pointer hover:ring-2 hover:ring-[var(--accent)] transition-all" onClick={() => setSelectedAgentId(agentId)} />
|
|
386
|
+
) : (
|
|
387
|
+
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs shrink-0 mt-0.5">🤖</div>
|
|
388
|
+
)
|
|
389
|
+
) : (
|
|
390
|
+
selectedConv.peerAvatar ? (
|
|
391
|
+
<img
|
|
392
|
+
src={selectedConv.peerAvatar}
|
|
393
|
+
alt=""
|
|
394
|
+
className="w-7 h-7 rounded-full bg-[var(--border)] shrink-0 mt-0.5 cursor-pointer hover:ring-2 hover:ring-[var(--accent)] transition-all"
|
|
395
|
+
onClick={() => {
|
|
396
|
+
const peerId = selectedConv.peerId;
|
|
397
|
+
if (peerId) setSelectedAgentId(peerId);
|
|
398
|
+
}}
|
|
399
|
+
/>
|
|
400
|
+
) : (
|
|
401
|
+
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-xs shrink-0 mt-0.5">
|
|
402
|
+
{isBoss ? '👤' : '🤖'}
|
|
403
|
+
</div>
|
|
404
|
+
)
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
<div className={`max-w-[70%] ${isRight ? 'text-right' : ''}`}>
|
|
408
|
+
{/* Name + Time */}
|
|
409
|
+
<div className={`flex items-center gap-2 mb-0.5 ${isRight ? 'flex-row-reverse' : ''}`}>
|
|
410
|
+
<span className="text-[10px] text-[var(--muted)]">
|
|
411
|
+
{isRight ? agentName : (firstMsg.fromAgentName || selectedConv.peerName)}
|
|
412
|
+
</span>
|
|
413
|
+
{firstMsg.time && (
|
|
414
|
+
<span className="text-[10px] text-[var(--muted)]/60">
|
|
415
|
+
{new Date(firstMsg.time).toLocaleTimeString()}
|
|
416
|
+
</span>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
{/* Bubble */}
|
|
420
|
+
{isMerged ? (
|
|
421
|
+
<div className={`inline-block rounded-2xl px-3 py-2 text-sm leading-relaxed text-left ${
|
|
422
|
+
isRight
|
|
423
|
+
? 'bg-indigo-500/15 border border-indigo-500/20 rounded-br-sm'
|
|
424
|
+
: 'bg-[var(--card)] border border-[var(--border)] rounded-bl-sm'
|
|
425
|
+
}`}>
|
|
426
|
+
{group.messages.map((msg, mi) => (
|
|
427
|
+
<div key={mi}>
|
|
428
|
+
{mi > 0 && <div className="border-t border-white/[0.06] my-1.5" />}
|
|
429
|
+
<div className="break-words chat-markdown">
|
|
430
|
+
{renderMentions(cleanMessageContent(msg.content), agentMap, setSelectedAgentId) || (
|
|
431
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={chatMarkdownComponents}>
|
|
432
|
+
{cleanMessageContent(msg.content)}
|
|
433
|
+
</ReactMarkdown>
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
))}
|
|
438
|
+
</div>
|
|
439
|
+
) : (
|
|
440
|
+
<div className={`inline-block rounded-2xl px-3 py-2 text-sm leading-relaxed text-left ${isRight
|
|
441
|
+
? 'bg-indigo-500/15 border border-indigo-500/20 rounded-br-sm'
|
|
442
|
+
: 'bg-[var(--card)] border border-[var(--border)] rounded-bl-sm'
|
|
443
|
+
}`}>
|
|
444
|
+
<div className="break-words chat-markdown">
|
|
445
|
+
{renderMentions(cleanMessageContent(firstMsg.content), agentMap, setSelectedAgentId) || (
|
|
446
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={chatMarkdownComponents}>
|
|
447
|
+
{cleanMessageContent(firstMsg.content)}
|
|
448
|
+
</ReactMarkdown>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
})
|
|
457
|
+
)}
|
|
458
|
+
<div ref={messagesEndRef} />
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
{/* 底部提示 */}
|
|
462
|
+
<div className="px-4 py-2 border-t border-[var(--border)] shrink-0 bg-[var(--background)]/30">
|
|
463
|
+
<div className="text-[10px] text-[var(--muted)] text-center flex items-center justify-center gap-1">
|
|
464
|
+
🔒 {t('agent.spyReadonly')}
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
</>
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
{/* 员工详情弹窗 */}
|
|
473
|
+
{selectedAgentId && (
|
|
474
|
+
<AgentDetailModal agentId={selectedAgentId} onClose={() => setSelectedAgentId(null)} />
|
|
475
|
+
)}
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AvatarGrid - 通用头像选择网格组件
|
|
5
|
+
* 方形网格视图,选中后主色调高亮背景,点击即选
|
|
6
|
+
*/
|
|
7
|
+
export default function AvatarGrid({ choices = [], selectedId, onSelect, cols = 8, gap = 'gap-1.5' }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className={`grid ${gap} overflow-auto pr-1`} style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
|
|
10
|
+
{choices.map((choice) => (
|
|
11
|
+
<button
|
|
12
|
+
key={choice.id}
|
|
13
|
+
onClick={() => onSelect(choice)}
|
|
14
|
+
className={`relative aspect-square rounded-lg transition-all overflow-hidden ${
|
|
15
|
+
selectedId === choice.id
|
|
16
|
+
? 'bg-[var(--accent)] p-1 scale-[1.02]'
|
|
17
|
+
: 'bg-[var(--border)] hover:bg-[var(--accent)]/30 hover:scale-[1.03]'
|
|
18
|
+
}`}
|
|
19
|
+
>
|
|
20
|
+
<img
|
|
21
|
+
src={choice.url}
|
|
22
|
+
alt="avatar"
|
|
23
|
+
className="w-full h-full object-cover rounded-md"
|
|
24
|
+
/>
|
|
25
|
+
</button>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { useStore } from '@/lib/client-store';
|
|
5
|
+
import { getAvatarChoices } from '@/lib/avatar';
|
|
6
|
+
import { useI18n } from '@/lib/i18n';
|
|
7
|
+
import CachedAvatar from './CachedAvatar';
|
|
8
|
+
import AvatarGrid from './AvatarGrid';
|
|
9
|
+
|
|
10
|
+
export default function BossProfileModal({ onClose }) {
|
|
11
|
+
const { t } = useI18n();
|
|
12
|
+
const { company, updateBossProfile } = useStore();
|
|
13
|
+
const [selectedAvatar, setSelectedAvatar] = useState(null);
|
|
14
|
+
const [avatarChoices, setAvatarChoices] = useState([]);
|
|
15
|
+
const [saving, setSaving] = useState(false);
|
|
16
|
+
const [saved, setSaved] = useState(false);
|
|
17
|
+
|
|
18
|
+
// 生成头像选项 (老板默认使用 male, 35 岁参数)
|
|
19
|
+
const [gender, setGender] = useState('male');
|
|
20
|
+
const [age, setAge] = useState(35);
|
|
21
|
+
|
|
22
|
+
const debounceTimer = useRef(null);
|
|
23
|
+
const refreshAvatarDebounced = useCallback((g, a) => {
|
|
24
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
25
|
+
debounceTimer.current = setTimeout(() => {
|
|
26
|
+
const choices = getAvatarChoices(20, g, a);
|
|
27
|
+
setAvatarChoices(choices);
|
|
28
|
+
}, 300);
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
refreshAvatarDebounced(gender, age);
|
|
33
|
+
return () => { if (debounceTimer.current) clearTimeout(debounceTimer.current); };
|
|
34
|
+
}, [gender, age, refreshAvatarDebounced]);
|
|
35
|
+
|
|
36
|
+
const refreshChoices = () => {
|
|
37
|
+
const choices = getAvatarChoices(20, gender, age);
|
|
38
|
+
setAvatarChoices(choices);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const previewAvatar = selectedAvatar?.url || company?.bossAvatar;
|
|
42
|
+
|
|
43
|
+
const handleSave = async () => {
|
|
44
|
+
if (!selectedAvatar) return;
|
|
45
|
+
setSaving(true);
|
|
46
|
+
setSaved(false);
|
|
47
|
+
try {
|
|
48
|
+
await updateBossProfile({ avatar: selectedAvatar.url });
|
|
49
|
+
setSaved(true);
|
|
50
|
+
setTimeout(() => setSaved(false), 2000);
|
|
51
|
+
} catch (e) { /* handled */ }
|
|
52
|
+
setSaving(false);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 !m-0" onClick={onClose}>
|
|
57
|
+
<div className="card max-w-lg w-full mx-4 max-h-[85vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
|
|
58
|
+
{/* Header */}
|
|
59
|
+
<div className="flex items-center justify-between pb-4 border-b border-[var(--border)]">
|
|
60
|
+
<div className="flex items-center gap-3">
|
|
61
|
+
{previewAvatar ? (
|
|
62
|
+
<CachedAvatar src={previewAvatar} alt="boss" className="w-12 h-12 rounded-full bg-[var(--border)]" />
|
|
63
|
+
) : (
|
|
64
|
+
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-xl font-bold">
|
|
65
|
+
👑
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
<div>
|
|
69
|
+
<h2 className="text-lg font-bold">{t('bossProfile.title')}</h2>
|
|
70
|
+
<p className="text-xs text-[var(--muted)]">{t('bossProfile.subtitle')}</p>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<button onClick={onClose} className="text-[var(--muted)] hover:text-white text-xl">✕</button>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Content */}
|
|
77
|
+
<div className="flex-1 overflow-auto py-4 space-y-5">
|
|
78
|
+
{/* Gender & Age */}
|
|
79
|
+
<div className="grid grid-cols-2 gap-4">
|
|
80
|
+
<div>
|
|
81
|
+
<label className="block text-sm font-medium mb-1.5 text-[var(--muted)]">{t('setup.gender')}</label>
|
|
82
|
+
<div className="flex gap-2">
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => { setGender('female'); setSelectedAvatar(null); }}
|
|
85
|
+
className={`flex-1 py-2 px-3 rounded-lg border text-sm transition-all ${
|
|
86
|
+
gender === 'female'
|
|
87
|
+
? 'border-pink-400 bg-pink-400/10 text-pink-300'
|
|
88
|
+
: 'border-[var(--border)] text-[var(--muted)] hover:border-[var(--accent)]/40'
|
|
89
|
+
}`}
|
|
90
|
+
>{t('setup.female')}</button>
|
|
91
|
+
<button
|
|
92
|
+
onClick={() => { setGender('male'); setSelectedAvatar(null); }}
|
|
93
|
+
className={`flex-1 py-2 px-3 rounded-lg border text-sm transition-all ${
|
|
94
|
+
gender === 'male'
|
|
95
|
+
? 'border-blue-400 bg-blue-400/10 text-blue-300'
|
|
96
|
+
: 'border-[var(--border)] text-[var(--muted)] hover:border-[var(--accent)]/40'
|
|
97
|
+
}`}
|
|
98
|
+
>{t('setup.male')}</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div>
|
|
102
|
+
<label className="block text-sm font-medium mb-1.5 text-[var(--muted)]">{t('setup.age', { n: age })}</label>
|
|
103
|
+
<div className="relative flex items-center gap-3">
|
|
104
|
+
<button
|
|
105
|
+
onClick={() => setAge(a => Math.max(18, a - 1))}
|
|
106
|
+
className="w-7 h-7 rounded-full border border-[var(--border)] text-[var(--muted)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-all flex items-center justify-center text-sm font-bold shrink-0"
|
|
107
|
+
>−</button>
|
|
108
|
+
<div className="flex-1 relative h-5 flex items-center">
|
|
109
|
+
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-[var(--border)] pointer-events-none" />
|
|
110
|
+
<div
|
|
111
|
+
className="absolute left-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-gradient-to-r from-[var(--accent)] to-purple-400 pointer-events-none"
|
|
112
|
+
style={{ width: `${((age - 18) / 42) * 100}%` }}
|
|
113
|
+
/>
|
|
114
|
+
<input
|
|
115
|
+
type="range"
|
|
116
|
+
min="18"
|
|
117
|
+
max="60"
|
|
118
|
+
value={age}
|
|
119
|
+
onChange={e => { setAge(Number(e.target.value)); setSelectedAvatar(null); }}
|
|
120
|
+
className="absolute inset-0 z-10 w-full appearance-none cursor-pointer bg-transparent [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--accent)] [&::-webkit-slider-thumb]:shadow-[0_0_6px_rgba(99,102,241,0.5)] [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:hover:scale-125 [&::-webkit-slider-thumb]:-mt-[5px]"
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
<button
|
|
124
|
+
onClick={() => setAge(a => Math.min(60, a + 1))}
|
|
125
|
+
className="w-7 h-7 rounded-full border border-[var(--border)] text-[var(--muted)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-all flex items-center justify-center text-sm font-bold shrink-0"
|
|
126
|
+
>+</button>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="flex justify-between text-[10px] text-[var(--muted)] mt-1 px-10">
|
|
129
|
+
<span>18</span><span>30</span><span>45</span><span>60</span>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* Avatar selection */}
|
|
135
|
+
<div>
|
|
136
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
137
|
+
<label className="text-sm font-medium text-[var(--muted)]">{t('bossProfile.avatarTitle')}</label>
|
|
138
|
+
<button
|
|
139
|
+
className="text-xs text-[var(--accent)] hover:underline flex items-center gap-1"
|
|
140
|
+
onClick={refreshChoices}
|
|
141
|
+
>{t('bossProfile.refreshAvatar')}</button>
|
|
142
|
+
</div>
|
|
143
|
+
<AvatarGrid
|
|
144
|
+
choices={avatarChoices}
|
|
145
|
+
selectedId={selectedAvatar?.id}
|
|
146
|
+
onSelect={setSelectedAvatar}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Footer */}
|
|
152
|
+
<div className="pt-4 border-t border-[var(--border)] flex items-center justify-end gap-2">
|
|
153
|
+
{saved && <span className="text-xs text-green-400 animate-fade-in">{t('bossProfile.saved')}</span>}
|
|
154
|
+
<button className="btn-secondary" onClick={onClose}>{t('common.cancel')}</button>
|
|
155
|
+
<button className="btn-primary" disabled={saving || !selectedAvatar} onClick={handleSave}>
|
|
156
|
+
{saving ? t('bossProfile.saving') : t('bossProfile.saveBtn')}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|