From f8e99a34c7e640f4753f8479a600c24362efcfee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9C=E5=8C=97=E5=B0=98?= <2678115663@qq.com> Date: Sat, 21 Mar 2026 22:26:09 +0800 Subject: [PATCH] =?UTF-8?q?=20=20feat:=20=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=BC=95=E7=94=A8=E6=B6=88=E6=81=AF=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许用户在设置中切换引用消息与正文的上下顺序,并使聊天页中的引用回复即时按所选样式展示。 Close#510 --- electron/services/config.ts | 2 + src/pages/ChatPage.scss | 9 ++- src/pages/ChatPage.tsx | 78 ++++++++++++++-------- src/pages/SettingsPage.scss | 128 ++++++++++++++++++++++++++++++++++++ src/pages/SettingsPage.tsx | 74 +++++++++++++++++++++ src/services/config.ts | 12 ++++ 6 files changed, 276 insertions(+), 27 deletions(-) diff --git a/electron/services/config.ts b/electron/services/config.ts index 4229069..c293ee1 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -53,6 +53,7 @@ interface ConfigSchema { notificationFilterList: string[] messagePushEnabled: boolean windowCloseBehavior: 'ask' | 'tray' | 'quit' + quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] } @@ -120,6 +121,7 @@ export class ConfigService { notificationFilterList: [], messagePushEnabled: false, windowCloseBehavior: 'ask', + quoteLayout: 'quote-top', wordCloudExcludeWords: [] } diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 8776e5f..89049bf 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2420,7 +2420,6 @@ background: rgba(0, 0, 0, 0.04); border-left: 2px solid var(--primary); padding: 6px 10px; - margin-bottom: 8px; border-radius: 4px; font-size: 13px; @@ -2482,6 +2481,14 @@ .bubble-content { -webkit-app-region: no-drag; + + &.quote-layout-top .quoted-message { + margin-bottom: 8px; + } + + &.quote-layout-bottom .quoted-message { + margin-top: 8px; + } } // 时间分隔 diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 07ef637..e9f8eda 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -52,6 +52,8 @@ interface GlobalMsgPrefixCacheEntry { completed: boolean } +type QuoteLayout = configService.QuoteLayout + const GLOBAL_MSG_PER_SESSION_LIMIT = 10 const GLOBAL_MSG_SEED_LIMIT = 120 const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3 @@ -7556,6 +7558,7 @@ function MessageBubble({ const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) const [senderName, setSenderName] = useState(undefined) const [quotedSenderName, setQuotedSenderName] = useState(undefined) + const [quoteLayout, setQuoteLayout] = useState('quote-top') const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) @@ -8549,6 +8552,18 @@ function MessageBubble({ myWxid ]) + useEffect(() => { + let cancelled = false + void configService.getQuoteLayout().then((layout) => { + if (!cancelled) setQuoteLayout(layout) + }).catch(() => { + if (!cancelled) setQuoteLayout('quote-top') + }) + return () => { + cancelled = true + } + }, []) + const locationMessageMeta = useMemo(() => { if (message.localType !== 48) return null const raw = message.rawContent || '' @@ -8584,6 +8599,31 @@ function MessageBubble({ // 是否有引用消息 const hasQuote = quotedContent.length > 0 const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName + const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => { + const quoteFirst = quoteLayout !== 'quote-bottom' + return ( +
+ {quoteFirst ? ( + <> + {quotedNode} + {messageNode} + + ) : ( + <> + {messageNode} + {quotedNode} + + )} +
+ ) + }, [quoteLayout]) + + const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => ( +
+ {displayQuotedSenderName && {displayQuotedSenderName}} + {contentNode} +
+ ), [displayQuotedSenderName]) const handlePlayVideo = useCallback(async () => { if (!videoInfo?.videoUrl) return @@ -9023,13 +9063,10 @@ function MessageBubble({ } return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderReferContent()} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderReferContent()),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -9122,13 +9159,10 @@ function MessageBubble({ const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' const referContent = message.quotedContent || q('refermsg > content') || '' return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderTextWithEmoji(cleanMessageContent(referContent))} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -9338,13 +9372,10 @@ function MessageBubble({ } return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderReferContent2()} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderReferContent2()),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -9623,14 +9654,9 @@ function MessageBubble({ // 带引用的消息 if (hasQuote) { - return ( -
-
- {displayQuotedSenderName && {displayQuotedSenderName}} - {renderTextWithEmoji(cleanMessageContent(quotedContent))} -
-
{renderTextWithEmoji(cleanedParsedContent)}
-
+ return renderBubbleWithQuote( + renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))), +
{renderTextWithEmoji(cleanedParsedContent)}
) } diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 6b8bf8c..2de9358 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1145,6 +1145,134 @@ } } +.quote-layout-picker { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-top: 10px; +} + +.quote-layout-card { + position: relative; + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 14px; + background: var(--bg-primary); + color: inherit; + cursor: pointer; + text-align: left; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + transform: translateY(-1px); + } + + &.active { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); + background: color-mix(in srgb, var(--bg-primary) 92%, var(--primary) 8%); + } +} + +.quote-layout-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.quote-layout-card-title-group { + display: flex; + flex-direction: column; + gap: 2px; +} + +.quote-layout-card-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.quote-layout-card-desc { + font-size: 12px; + color: var(--text-tertiary); +} + +.quote-layout-card-check { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid var(--border-color); + color: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s ease; + + &.active { + border-color: var(--primary); + background: var(--primary); + color: #fff; + } +} + +.quote-layout-preview { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border-radius: 12px; + background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + min-height: 112px; + + &.quote-bottom { + .quote-layout-preview-message { + order: 1; + } + + .quote-layout-preview-quote { + order: 2; + } + } +} + +.quote-layout-preview-quote { + padding: 8px 10px; + border-left: 2px solid var(--primary); + border-radius: 8px; + background: rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + gap: 3px; +} + +.quote-layout-preview-sender { + font-size: 12px; + font-weight: 600; + color: var(--primary); +} + +.quote-layout-preview-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +.quote-layout-preview-message { + align-self: flex-start; + max-width: 88%; + padding: 9px 12px; + border-radius: 12px; + background: color-mix(in srgb, var(--primary) 14%, var(--bg-primary)); + color: var(--text-primary); + font-size: 13px; + line-height: 1.45; +} + .theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index bfed9b1..36fc4ed 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -118,6 +118,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterList, setNotificationFilterList] = useState([]) const [windowCloseBehavior, setWindowCloseBehavior] = useState('ask') + const [quoteLayout, setQuoteLayout] = useState('quote-top') const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) @@ -314,6 +315,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationFilterList = await configService.getNotificationFilterList() const savedMessagePushEnabled = await configService.getMessagePushEnabled() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() + const savedQuoteLayout = await configService.getQuoteLayout() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() @@ -351,6 +353,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationFilterList(savedNotificationFilterList) setMessagePushEnabled(savedMessagePushEnabled) setWindowCloseBehavior(savedWindowCloseBehavior) + setQuoteLayout(savedQuoteLayout) const savedExcludeWords = await configService.getWordCloudExcludeWords() setWordCloudExcludeWords(savedExcludeWords) @@ -1058,6 +1061,77 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ))} +
+ + 选择聊天中引用消息与正文的上下顺序,右侧预览会同步展示布局差异。 +
+ {[ + { + value: 'quote-top' as const, + label: '引用在上', + description: '更接近当前 WeFlow 风格', + successMessage: '已切换为引用在上样式' + }, + { + value: 'quote-bottom' as const, + label: '正文在上', + description: '更接近微信 / 密语风格', + successMessage: '已切换为正文在上样式' + } + ].map(option => { + const selected = quoteLayout === option.value + const quotePreview = ( +
+ 张三 + 这是一条被引用的消息 +
+ ) + const messagePreview = ( +
这是当前发送的回复内容
+ ) + + return ( + + ) + })} +
+
+
diff --git a/src/services/config.ts b/src/services/config.ts index f5bfb53..31f8f00 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -66,6 +66,7 @@ export const CONFIG_KEYS = { NOTIFICATION_FILTER_LIST: 'notificationFilterList', MESSAGE_PUSH_ENABLED: 'messagePushEnabled', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', + QUOTE_LAYOUT: 'quoteLayout', // 词云 WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords', @@ -90,6 +91,7 @@ export interface ExportDefaultMediaConfig { } export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' +export type QuoteLayout = 'quote-top' | 'quote-bottom' const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { images: true, @@ -1409,6 +1411,16 @@ export async function setWindowCloseBehavior(behavior: WindowCloseBehavior): Pro await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior) } +export async function getQuoteLayout(): Promise { + const value = await config.get(CONFIG_KEYS.QUOTE_LAYOUT) + if (value === 'quote-bottom') return value + return 'quote-top' +} + +export async function setQuoteLayout(layout: QuoteLayout): Promise { + await config.set(CONFIG_KEYS.QUOTE_LAYOUT, layout) +} + // 获取词云排除词列表 export async function getWordCloudExcludeWords(): Promise { const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)