|
|
@@ -0,0 +1,374 @@
|
|
|
+import React, { useCallback, useEffect, useState } from 'react'
|
|
|
+import { BsSearch } from 'react-icons/bs'
|
|
|
+import { api, UserContact } from '../../services/api'
|
|
|
+import { logger } from '../../utils/logger'
|
|
|
+import { Contact } from '../../types'
|
|
|
+import { getDefaultAvatar } from '../../utils/avatarUtils'
|
|
|
+
|
|
|
+type Tab = 'recent' | 'address'
|
|
|
+
|
|
|
+function displayNameForUserContact(c: UserContact): string {
|
|
|
+ const parts: string[] = []
|
|
|
+ if (c.name) parts.push(c.name)
|
|
|
+ if (c.english_name) parts.push(c.english_name)
|
|
|
+ return parts.length > 0 ? parts.join(' / ') : `用户${c.id}`
|
|
|
+}
|
|
|
+
|
|
|
+interface ForwardMessageModalProps {
|
|
|
+ isOpen: boolean
|
|
|
+ onClose: () => void
|
|
|
+ token: string
|
|
|
+ recentContacts: Contact[]
|
|
|
+ isSubmitting: boolean
|
|
|
+ onConfirm: (receiverIds: number[]) => void | Promise<void>
|
|
|
+}
|
|
|
+
|
|
|
+export const ForwardMessageModal: React.FC<ForwardMessageModalProps> = ({
|
|
|
+ isOpen,
|
|
|
+ onClose,
|
|
|
+ token,
|
|
|
+ recentContacts,
|
|
|
+ isSubmitting,
|
|
|
+ onConfirm
|
|
|
+}) => {
|
|
|
+ const [tab, setTab] = useState<Tab>('recent')
|
|
|
+ const [selected, setSelected] = useState<Set<number>>(() => new Set())
|
|
|
+ const [nameById, setNameById] = useState<Record<number, string>>({})
|
|
|
+
|
|
|
+ const [searchKeyword, setSearchKeyword] = useState('')
|
|
|
+ const [addressResults, setAddressResults] = useState<UserContact[]>([])
|
|
|
+ const [addressLoading, setAddressLoading] = useState(false)
|
|
|
+ const [addressError, setAddressError] = useState('')
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!isOpen) {
|
|
|
+ setTab('recent')
|
|
|
+ setSelected(new Set())
|
|
|
+ setNameById({})
|
|
|
+ setSearchKeyword('')
|
|
|
+ setAddressResults([])
|
|
|
+ setAddressError('')
|
|
|
+ }
|
|
|
+ }, [isOpen])
|
|
|
+
|
|
|
+ const runAddressSearch = useCallback(
|
|
|
+ async (q: string) => {
|
|
|
+ if (!token) return
|
|
|
+ setAddressLoading(true)
|
|
|
+ setAddressError('')
|
|
|
+ try {
|
|
|
+ const list = await api.searchContacts(token, q, 50)
|
|
|
+ setAddressResults(list)
|
|
|
+ setNameById(prev => {
|
|
|
+ const n = { ...prev }
|
|
|
+ for (const c of list) {
|
|
|
+ n[c.id] = displayNameForUserContact(c)
|
|
|
+ }
|
|
|
+ return n
|
|
|
+ })
|
|
|
+ } catch (e: unknown) {
|
|
|
+ setAddressError(e instanceof Error ? e.message : '搜索失败')
|
|
|
+ setAddressResults([])
|
|
|
+ } finally {
|
|
|
+ setAddressLoading(false)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [token]
|
|
|
+ )
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!isOpen || tab !== 'address' || !token) return
|
|
|
+ const q = searchKeyword.trim()
|
|
|
+ if (q === '') {
|
|
|
+ setAddressResults([])
|
|
|
+ setAddressError('')
|
|
|
+ setAddressLoading(false)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const t = window.setTimeout(() => {
|
|
|
+ void runAddressSearch(q)
|
|
|
+ }, 300)
|
|
|
+ return () => clearTimeout(t)
|
|
|
+ }, [isOpen, tab, searchKeyword, token, runAddressSearch])
|
|
|
+
|
|
|
+ const recentSorted = React.useMemo(() => {
|
|
|
+ return recentContacts
|
|
|
+ .filter(c => c.id > 0)
|
|
|
+ .slice()
|
|
|
+ .sort((a, b) => (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0))
|
|
|
+ }, [recentContacts])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!isOpen) return
|
|
|
+ setNameById(prev => {
|
|
|
+ const n = { ...prev }
|
|
|
+ for (const c of recentSorted) {
|
|
|
+ n[c.id] = c.name
|
|
|
+ }
|
|
|
+ return n
|
|
|
+ })
|
|
|
+ }, [isOpen, recentSorted])
|
|
|
+
|
|
|
+ const toggleId = (id: number) => {
|
|
|
+ if (id <= 0) return
|
|
|
+ setSelected(prev => {
|
|
|
+ const next = new Set(prev)
|
|
|
+ if (next.has(id)) next.delete(id)
|
|
|
+ else next.add(id)
|
|
|
+ return next
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleConfirm = async () => {
|
|
|
+ if (selected.size === 0) return
|
|
|
+ try {
|
|
|
+ await onConfirm([...selected])
|
|
|
+ } catch (e: unknown) {
|
|
|
+ logger.error('ForwardMessageModal: onConfirm', { error: e })
|
|
|
+ alert(e instanceof Error ? e.message : '转发失败')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isOpen) return null
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'fixed',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ bottom: 0,
|
|
|
+ backgroundColor: 'rgba(0,0,0,0.5)',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ zIndex: 1000
|
|
|
+ }}
|
|
|
+ onClick={onClose}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ backgroundColor: '#fff',
|
|
|
+ borderRadius: '8px',
|
|
|
+ width: '100%',
|
|
|
+ maxWidth: '440px',
|
|
|
+ maxHeight: '80vh',
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column',
|
|
|
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
|
|
+ overflow: 'hidden'
|
|
|
+ }}
|
|
|
+ onClick={e => e.stopPropagation()}
|
|
|
+ >
|
|
|
+ <div style={{ padding: '16px 20px', borderBottom: '1px solid #eee' }}>
|
|
|
+ <h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600 }}>转发给</h2>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div style={{ display: 'flex', borderBottom: '1px solid #eee' }}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setTab('recent')}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ padding: '12px',
|
|
|
+ border: 'none',
|
|
|
+ background: tab === 'recent' ? '#f0f9ff' : '#fff',
|
|
|
+ color: tab === 'recent' ? '#0066cc' : '#666',
|
|
|
+ fontWeight: tab === 'recent' ? 600 : 400,
|
|
|
+ cursor: 'pointer'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 近期
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setTab('address')}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ padding: '12px',
|
|
|
+ border: 'none',
|
|
|
+ background: tab === 'address' ? '#f0f9ff' : '#fff',
|
|
|
+ color: tab === 'address' ? '#0066cc' : '#666',
|
|
|
+ fontWeight: tab === 'address' ? 600 : 400,
|
|
|
+ cursor: 'pointer'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 通讯录
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
|
+ {tab === 'recent' && (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ overflowY: 'auto',
|
|
|
+ padding: '8px 0',
|
|
|
+ WebkitAppRegion: 'no-drag' as React.CSSProperties
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {recentSorted.length === 0 ? (
|
|
|
+ <div style={{ textAlign: 'center', color: '#999', padding: '24px' }}>暂无近期会话</div>
|
|
|
+ ) : (
|
|
|
+ recentSorted.map(c => (
|
|
|
+ <label
|
|
|
+ key={c.id}
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ padding: '10px 16px',
|
|
|
+ cursor: 'pointer',
|
|
|
+ gap: '12px',
|
|
|
+ borderBottom: '1px solid #f5f5f5'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ checked={selected.has(c.id)}
|
|
|
+ onChange={() => toggleId(c.id)}
|
|
|
+ />
|
|
|
+ <div style={{ width: 40, height: 40, borderRadius: '4px', overflow: 'hidden' }}>{c.avatar}</div>
|
|
|
+ <div style={{ flex: 1, minWidth: 0 }}>
|
|
|
+ <div style={{ fontSize: '15px', fontWeight: 500, color: '#333' }}>{c.name}</div>
|
|
|
+ </div>
|
|
|
+ </label>
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {tab === 'address' && (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column',
|
|
|
+ minHeight: 0
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ padding: '10px 16px',
|
|
|
+ borderBottom: '1px solid #eee',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ gap: '8px'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <BsSearch style={{ color: '#999' }} />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ placeholder="搜索(手机号/姓名/英文名)"
|
|
|
+ value={searchKeyword}
|
|
|
+ onChange={e => setSearchKeyword(e.target.value)}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ border: 'none',
|
|
|
+ outline: 'none',
|
|
|
+ fontSize: '14px'
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ style={{ flex: 1, overflowY: 'auto', minHeight: 0, WebkitAppRegion: 'no-drag' as React.CSSProperties }}
|
|
|
+ >
|
|
|
+ {addressLoading && (
|
|
|
+ <div style={{ textAlign: 'center', color: '#999', padding: '16px' }}>加载中…</div>
|
|
|
+ )}
|
|
|
+ {addressError && <div style={{ color: '#c00', padding: '8px 16px' }}>{addressError}</div>}
|
|
|
+ {!addressLoading && addressResults.map(c => (
|
|
|
+ <label
|
|
|
+ key={c.id}
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ padding: '10px 16px',
|
|
|
+ cursor: 'pointer',
|
|
|
+ gap: '12px',
|
|
|
+ borderBottom: '1px solid #f5f5f5'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ checked={selected.has(c.id)}
|
|
|
+ onChange={() => toggleId(c.id)}
|
|
|
+ />
|
|
|
+ <div style={{ width: 40, height: 40, borderRadius: '4px', overflow: 'hidden' }}>
|
|
|
+ {getDefaultAvatar(c.id, c.name || c.english_name, { sizePx: 40 })}
|
|
|
+ </div>
|
|
|
+ <div style={{ flex: 1, minWidth: 0 }}>
|
|
|
+ <div style={{ fontSize: '15px', fontWeight: 500, color: '#333' }}>
|
|
|
+ {displayNameForUserContact(c)}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </label>
|
|
|
+ ))}
|
|
|
+ {!addressLoading && addressResults.length === 0 && !addressError && (
|
|
|
+ <div style={{ textAlign: 'center', color: '#999', padding: '24px' }}>
|
|
|
+ {searchKeyword.trim()
|
|
|
+ ? '无匹配联系人'
|
|
|
+ : '请输入关键词搜索联系人(手机号/姓名/英文名)'}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ padding: '12px 16px',
|
|
|
+ borderTop: '1px solid #eee',
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column',
|
|
|
+ gap: '8px'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div style={{ fontSize: '13px', color: '#666' }}>已选 {selected.size} 人</div>
|
|
|
+ {selected.size > 0 && (
|
|
|
+ <div style={{ fontSize: '12px', color: '#999', lineHeight: 1.4, maxHeight: '48px', overflow: 'hidden' }}>
|
|
|
+ {Array.from(selected)
|
|
|
+ .map(id => nameById[id] || `用户${id}`)
|
|
|
+ .join('、')}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <div style={{ display: 'flex', gap: '8px' }}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onClose}
|
|
|
+ disabled={isSubmitting}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ padding: '10px',
|
|
|
+ border: '1px solid #ddd',
|
|
|
+ borderRadius: '6px',
|
|
|
+ background: '#fff',
|
|
|
+ cursor: isSubmitting ? 'not-allowed' : 'pointer'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => void handleConfirm()}
|
|
|
+ disabled={isSubmitting || selected.size === 0}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ padding: '10px',
|
|
|
+ border: 'none',
|
|
|
+ borderRadius: '6px',
|
|
|
+ background: selected.size === 0 || isSubmitting ? '#ccc' : '#1aad19',
|
|
|
+ color: '#fff',
|
|
|
+ fontWeight: 500,
|
|
|
+ cursor: isSubmitting || selected.size === 0 ? 'not-allowed' : 'pointer'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {isSubmitting ? '发送中…' : '确定转发'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|