feat(web): make instance detail the operations workbench

This commit is contained in:
chick
2026-07-19 14:05:06 +08:00
parent f7d69a0fa7
commit 75682b8134
8 changed files with 663 additions and 140 deletions
+6 -5
View File
@@ -162,9 +162,10 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(within(card).getByText(/这是一条用于聚合页展示/)).toBeTruthy();
expect(within(card).getByText(/2026/)).toBeTruthy();
expect(within(card).queryByText('能力未知')).toBeNull();
expect(within(card).getByRole('link', { name: '查看 Bravo 的短信' }).getAttribute('href')).toBe(
'/instances/bravo/messages',
);
expect(
within(card).getByRole('link', { name: '打开 Bravo 实例仪表盘' }).getAttribute('href'),
).toBe('/instances/bravo/overview');
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe(
'/settings/instances/bravo',
);
@@ -241,7 +242,7 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
expect(await screen.findByText('Bravo')).toBeTruthy();
expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
'/settings/instances/bravo',
);
@@ -352,7 +353,7 @@ describe('React AppShell and Fleet vertical slice', () => {
const { rerender } = render(
<AppShell pathname="/instances/owner/messages" instance={instance} />,
);
expect(screen.getByRole('heading', { name: '短信' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '短信管理' })).toBeTruthy();
expect(screen.getByText('Owner modem')).toBeTruthy();
expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe(
'https://owner.example',
+45
View File
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { cleanup, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { FleetPage, type FleetSnapshot } from './fleet-page.js';
const snapshot: FleetSnapshot = {
instances: [
{
id: 'alpha',
name: 'Alpha modem',
url: 'http://192.168.1.2',
tags: [],
},
],
statuses: new Map([
[
'alpha',
{
instanceId: 'alpha',
connectivity: 'online',
authentication: 'authenticated',
freshness: 'fresh',
capabilities: ['overview', 'messages'],
anomalies: [],
},
],
]),
};
afterEach(cleanup);
describe('FleetPage card navigation', () => {
it('makes the whole instance card open its dashboard while admin controls stay separately interactive', () => {
render(<FleetPage initialData={snapshot} />);
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
const entry = within(card).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' });
expect(entry.getAttribute('href')).toBe('/instances/alpha/overview');
expect(entry.classList.contains('fleet-card-entry')).toBe(true);
expect(within(card).getByRole('link', { name: '编辑 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('button', { name: '删除 Alpha modem' })).toBeTruthy();
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
});
});
+7 -10
View File
@@ -458,10 +458,14 @@ export function FleetPage({
key={row.id}
>
<header>
<div>
<a
className="fleet-card-entry"
href={`/instances/${encodeURIComponent(row.id)}/overview`}
aria-label={`打开 ${row.displayName} 实例仪表盘`}
>
<h2>{row.displayName}</h2>
<code>{row.id}</code>
</div>
</a>
<span className={`status status-${row.statusKind}`}>
{STATUS_LABELS[row.statusKind]}
</span>
@@ -520,14 +524,7 @@ export function FleetPage({
<p className="fleet-card-sms-empty"></p>
)}
</section>
<div className="fleet-card-actions">
<a
className="primary-action"
href={`/instances/${encodeURIComponent(row.id)}/messages`}
aria-label={`查看 ${row.displayName} 的短信`}
>
</a>
<div className="fleet-card-actions fleet-card-admin-actions">
<a
href={`/settings/instances/${encodeURIComponent(row.id)}`}
aria-label={`编辑 ${row.displayName}`}
@@ -46,11 +46,11 @@ describe('InstanceDetail', () => {
const links = navigation.getAllByRole('link');
expect(links).toHaveLength(2);
expect(links.map((link) => [link.textContent, link.getAttribute('href')])).toEqual([
['短信', '/instances/owner/messages'],
['设备信息', '/instances/owner/overview'],
['实例仪表盘', '/instances/owner/overview'],
['短信管理', '/instances/owner/messages'],
]);
expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull();
expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
expect(screen.queryByText('能力状态未知。')).toBeNull();
expect(screen.queryByText('此调制解调器不支持语音功能。')).toBeNull();
expect(screen.queryByText('能力探测结果未包含 eSIM。')).toBeNull();
@@ -65,7 +65,7 @@ describe('InstanceDetail', () => {
capabilities={capabilities}
/>,
);
expect(screen.getByRole('heading', { name: '短信' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '短信管理' })).toBeTruthy();
expect(screen.queryByRole('heading', { name: '消息' })).toBeNull();
rerender(
@@ -76,7 +76,7 @@ describe('InstanceDetail', () => {
capabilities={capabilities}
/>,
);
expect(screen.getByRole('heading', { name: '设备信息' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
expect(screen.queryByRole('heading', { name: '概览' })).toBeNull();
});
@@ -159,7 +159,7 @@ describe('InstanceDetail', () => {
expect(pending.get('owner')?.signal.aborted).toBe(true);
pending.get('second')?.resolve({ overview: { state: 'supported' } });
expect(await instanceNavigation().findByRole('link', { name: '设备信息' })).toBeTruthy();
expect(await instanceNavigation().findByRole('link', { name: '实例仪表盘' })).toBeTruthy();
pending
.get('owner')
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
+3 -3
View File
@@ -28,10 +28,10 @@ export interface InstanceDetailProps {
}
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
overview: '设备信息',
overview: '实例仪表盘',
cellular: '蜂窝网络',
'device-network': '设备网络',
messages: '短信',
messages: '短信管理',
calls: '通话',
esim: 'eSIM',
notifications: '通知',
@@ -39,7 +39,7 @@ export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> =
ota: 'OTA',
};
const PRIMARY_MODULES = ['messages', 'overview'] as const satisfies readonly InstanceModule[];
const PRIMARY_MODULES = ['overview', 'messages'] as const satisfies readonly InstanceModule[];
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
degraded: '此模块可用,但功能受限。',
+168 -2
View File
@@ -17,7 +17,39 @@ const instance: InstanceContext = {
afterEach(cleanup);
describe('MessagesModule', () => {
it('organizes messages as conversations and starts a new conversation before composing', async () => {
const user = userEvent.setup();
const dataSource: MessagesDataSource = {
load: vi.fn().mockResolvedValue({
messages: [
{
id: '32',
direction: 'incoming',
phoneNumber: '10086',
content: '余额提醒',
timestamp: '2026-07-18 09:09:20',
status: 'received',
},
],
}),
send: vi.fn(),
};
render(<MessagesModule instance={instance} dataSource={dataSource} />);
const conversations = await screen.findByRole('region', { name: '短信对话' });
expect(within(conversations).getByRole('button', { name: /10086/ })).toBeTruthy();
expect(screen.getByRole('region', { name: '当前短信会话' }).textContent).toContain(
'选择一个对话',
);
await user.click(within(conversations).getByRole('button', { name: '新建对话' }));
const dialog = screen.getByRole('dialog', { name: '新建对话' });
expect(within(dialog).getByRole('textbox', { name: '手机号' })).toBeTruthy();
expect(screen.queryByRole('textbox', { name: '短信内容' })).toBeNull();
});
it('shows the real message list including content and never exposes PDU', async () => {
const user = userEvent.setup();
const dataSource: MessagesDataSource = {
load: vi.fn().mockResolvedValue({
messages: [
@@ -35,11 +67,142 @@ describe('MessagesModule', () => {
send: vi.fn(),
};
render(<MessagesModule instance={instance} dataSource={dataSource} />);
expect(await screen.findByText('余额提醒')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: /10086/ }));
expect((await screen.findAllByText('余额提醒')).length).toBeGreaterThanOrEqual(2);
expect(screen.getByText(/收到 · 10086/)).toBeTruthy();
expect(document.body.textContent).not.toContain('secret-pdu');
});
it('clears recipient-bound drafts when the conversation changes', async () => {
const user = userEvent.setup();
const dataSource: MessagesDataSource = {
load: vi.fn().mockResolvedValue({
messages: [
{
id: 'one',
direction: 'incoming',
phoneNumber: '10086',
content: 'A',
timestamp: '2026-07-18 09:09:20',
status: 'received',
},
{
id: 'two',
direction: 'incoming',
phoneNumber: '10010',
content: 'B',
timestamp: '2026-07-18 09:10:20',
status: 'received',
},
],
}),
send: vi.fn(),
};
render(<MessagesModule instance={instance} dataSource={dataSource} />);
await user.click(await screen.findByRole('button', { name: /10086/ }));
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'draft for 10086');
await user.click(screen.getByRole('button', { name: /10010/ }));
expect((screen.getByRole('textbox', { name: '短信内容' }) as HTMLTextAreaElement).value).toBe(
'',
);
});
it('discards a pending confirmation when the instance owner changes', async () => {
const user = userEvent.setup();
const send = vi.fn().mockResolvedValue(undefined);
const dataSource: MessagesDataSource = {
load: vi.fn().mockResolvedValue({ messages: [] }),
send,
};
const second = { ...instance, id: 'beta', name: 'Beta' };
const { rerender } = render(<MessagesModule instance={instance} dataSource={dataSource} />);
await user.click(await screen.findByRole('button', { name: '新建对话' }));
const dialog = screen.getByRole('dialog', { name: '新建对话' });
await user.type(within(dialog).getByRole('textbox', { name: '手机号' }), '10086');
await user.click(within(dialog).getByRole('button', { name: '开始对话' }));
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'owner-bound draft');
await user.click(screen.getByRole('button', { name: '发送短信' }));
expect(screen.getByRole('region', { name: '确认发送短信' })).toBeTruthy();
rerender(<MessagesModule instance={second} dataSource={dataSource} />);
expect(screen.queryByRole('region', { name: '确认发送短信' })).toBeNull();
expect(send).not.toHaveBeenCalled();
});
it('drops an old confirmation when starting another recipient conversation', async () => {
const user = userEvent.setup();
const dataSource: MessagesDataSource = {
load: vi.fn().mockResolvedValue({
messages: [
{
id: 'one',
direction: 'incoming',
phoneNumber: '10086',
content: 'A',
timestamp: '2026-07-18 09:09:20',
status: 'received',
},
],
}),
send: vi.fn(),
};
render(<MessagesModule instance={instance} dataSource={dataSource} />);
await user.click(await screen.findByRole('button', { name: /10086/ }));
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'old confirmation');
await user.click(screen.getByRole('button', { name: '发送短信' }));
expect(screen.getByRole('region', { name: '确认发送短信' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '新建对话' }));
const dialog = screen.getByRole('dialog', { name: '新建对话' });
await user.type(within(dialog).getByRole('textbox', { name: '手机号' }), '10010');
await user.click(within(dialog).getByRole('button', { name: '开始对话' }));
expect(screen.queryByRole('region', { name: '确认发送短信' })).toBeNull();
});
it('preserves a new recipient draft when an earlier send completes late', async () => {
const user = userEvent.setup();
let resolveSend: (() => void) | undefined;
const send = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveSend = resolve;
}),
);
const dataSource: MessagesDataSource = {
load: vi.fn().mockResolvedValue({
messages: [
{
id: 'one',
direction: 'incoming',
phoneNumber: '10086',
content: 'A',
timestamp: '2026-07-18 09:09:20',
status: 'received',
},
{
id: 'two',
direction: 'incoming',
phoneNumber: '10010',
content: 'B',
timestamp: '2026-07-18 09:10:20',
status: 'received',
},
],
}),
send,
};
render(<MessagesModule instance={instance} dataSource={dataSource} />);
await user.click(await screen.findByRole('button', { name: /10086/ }));
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'send to 10086');
await user.click(screen.getByRole('button', { name: '发送短信' }));
await user.click(screen.getByRole('button', { name: '确认并发送' }));
await user.click(screen.getByRole('button', { name: /10010/ }));
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'draft for 10010');
resolveSend?.();
expect(await screen.findByDisplayValue('draft for 10010')).toBeTruthy();
});
it('requires an explicit review step before it submits exactly once', async () => {
const user = userEvent.setup();
const send = vi.fn().mockResolvedValue(undefined);
@@ -48,7 +211,10 @@ describe('MessagesModule', () => {
send,
};
render(<MessagesModule instance={instance} dataSource={dataSource} />);
await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086');
await user.click(await screen.findByRole('button', { name: '新建对话' }));
const dialog = screen.getByRole('dialog', { name: '新建对话' });
await user.type(within(dialog).getByRole('textbox', { name: '手机号' }), '10086');
await user.click(within(dialog).getByRole('button', { name: '开始对话' }));
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL');
await user.click(screen.getByRole('button', { name: '发送短信' }));
expect(send).not.toHaveBeenCalled();
+234 -80
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { InstanceContext } from '../app-shell.js';
@@ -34,12 +34,39 @@ const directionLabel = (value: string): string =>
? '发送'
: '未知';
interface Conversation {
readonly phoneNumber: string;
readonly messages: readonly SmsMessage[];
readonly latest: SmsMessage;
}
function conversationsFor(messages: readonly SmsMessage[]): readonly Conversation[] {
const groups = new Map<string, SmsMessage[]>();
for (const message of messages) {
const group = groups.get(message.phoneNumber) ?? [];
group.push(message);
groups.set(message.phoneNumber, group);
}
return [...groups.entries()]
.map(([phoneNumber, group]) => ({
phoneNumber,
messages: group,
latest: group[group.length - 1]!,
}))
.sort((left, right) => right.latest.timestamp.localeCompare(left.latest.timestamp));
}
export function MessagesModule({ instance, dataSource, refreshSignal }: MessagesModuleProps) {
const owner = useRef(0);
const lifecycle = useRef(0);
const conversationLifecycle = useRef(0);
const instanceOwner = useRef(instance.id);
const [messages, setMessages] = useState<readonly SmsMessage[]>();
const [loadError, setLoadError] = useState(false);
const [retry, setRetry] = useState(0);
const [phoneNumber, setPhoneNumber] = useState('');
const [selectedPhone, setSelectedPhone] = useState<string>();
const [newConversationOpen, setNewConversationOpen] = useState(false);
const [newPhoneNumber, setNewPhoneNumber] = useState('');
const [content, setContent] = useState('');
const [pendingSend, setPendingSend] = useState<SendMessageInput>();
const [sending, setSending] = useState(false);
@@ -47,9 +74,22 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
useEffect(() => {
const request = ++owner.current;
const ownerChanged = instanceOwner.current !== instance.id;
if (ownerChanged) lifecycle.current += 1;
instanceOwner.current = instance.id;
const controller = new AbortController();
setMessages(undefined);
setLoadError(false);
if (ownerChanged) {
conversationLifecycle.current += 1;
setSelectedPhone(undefined);
setNewConversationOpen(false);
setNewPhoneNumber('');
setContent('');
setPendingSend(undefined);
setSending(false);
setSendState(undefined);
}
if (!dataSource) return () => controller.abort();
void dataSource.load(instance.id, controller.signal).then(
(snapshot) => {
@@ -62,20 +102,37 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
return () => controller.abort();
}, [dataSource, instance.id, refreshSignal, retry]);
const conversations = useMemo(() => conversationsFor(messages ?? []), [messages]);
const activeConversation = conversations.find((item) => item.phoneNumber === selectedPhone);
async function send(input: SendMessageInput): Promise<void> {
if (!dataSource || sending) return;
const sendOwner = instance.id;
const sendLifecycle = lifecycle.current;
const sendConversationLifecycle = conversationLifecycle.current;
if (instanceOwner.current !== sendOwner) return;
setSending(true);
setSendState(undefined);
try {
await dataSource.send(instance.id, input);
setContent('');
setPendingSend(undefined);
setSendState('success');
await dataSource.send(sendOwner, input);
if (instanceOwner.current !== sendOwner || lifecycle.current !== sendLifecycle) return;
const sameConversation = conversationLifecycle.current === sendConversationLifecycle;
if (sameConversation) {
setContent((current) => (current === input.content ? '' : current));
setPendingSend(undefined);
setSendState('success');
}
setRetry((value) => value + 1);
} catch {
setSendState('error');
if (
instanceOwner.current === sendOwner &&
lifecycle.current === sendLifecycle &&
conversationLifecycle.current === sendConversationLifecycle
)
setSendState('error');
} finally {
setSending(false);
if (instanceOwner.current === sendOwner && lifecycle.current === sendLifecycle)
setSending(false);
}
}
@@ -87,91 +144,188 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
);
return (
<div className="messages-module">
<section className="messages-card" aria-labelledby="send-sms-title">
<h2 id="send-sms-title"></h2>
<p></p>
<form
onSubmit={(event) => {
event.preventDefault();
setPendingSend({ phoneNumber: phoneNumber.trim(), content: content.trim() });
}}
>
<label>
<input
name="phoneNumber"
value={phoneNumber}
onChange={(event) => setPhoneNumber(event.target.value)}
minLength={3}
maxLength={32}
required
/>
</label>
<label>
<textarea
name="content"
value={content}
onChange={(event) => setContent(event.target.value)}
maxLength={1600}
required
/>
</label>
<button type="submit" disabled={sending || !phoneNumber.trim() || !content.trim()}>
{sending ? '正在发送…' : '发送短信'}
<div className="messages-workbench">
<section className="conversation-panel" role="region" aria-label="短信对话">
<header className="conversation-panel-header">
<div>
<h2></h2>
<span>{conversations.length} </span>
</div>
<button type="button" aria-label="新建对话" onClick={() => setNewConversationOpen(true)}>
</button>
</form>
{sendState === 'success' ? <p role="status"></p> : null}
{sendState === 'error' ? <p role="alert"></p> : null}
{pendingSend ? (
<section className="send-confirmation" role="region" aria-label="确认发送短信">
<h3></h3>
<p>{pendingSend.phoneNumber}</p>
<p>{pendingSend.content}</p>
<p></p>
<div className="fleet-card-actions">
<button type="button" disabled={sending} onClick={() => void send(pendingSend)}>
{sending ? '正在发送…' : '确认并发送'}
</button>
<button type="button" disabled={sending} onClick={() => setPendingSend(undefined)}>
</button>
</div>
</section>
) : null}
</section>
<section className="messages-card" aria-labelledby="sms-list-title">
<h2 id="sms-list-title"></h2>
</header>
{loadError ? (
<div role="alert">
<p></p>
<div role="alert" className="conversation-state">
<p></p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</div>
) : messages === undefined ? (
<p role="status"></p>
) : messages.length === 0 ? (
<p></p>
<p role="status" className="conversation-state">
</p>
) : conversations.length === 0 ? (
<p className="conversation-state"></p>
) : (
<ol className="sms-list">
{messages.map((message) => (
<li key={message.id} className="sms-message-card">
<header>
<strong>
{directionLabel(message.direction)} · {message.phoneNumber}
</strong>
<time>{message.timestamp}</time>
</header>
<p>{message.content}</p>
<small>{message.status}</small>
<ol className="conversation-list">
{conversations.map((conversation) => (
<li key={conversation.phoneNumber}>
<button
type="button"
className="conversation-entry"
aria-pressed={selectedPhone === conversation.phoneNumber}
aria-label={`${conversation.phoneNumber}${conversation.messages.length} 条短信`}
onClick={() => {
conversationLifecycle.current += 1;
setSelectedPhone(conversation.phoneNumber);
setContent('');
setPendingSend(undefined);
setSendState(undefined);
}}
>
<span className="conversation-entry-title">
<strong>{conversation.phoneNumber}</strong>
<time>{conversation.latest.timestamp}</time>
</span>
<span>{conversation.latest.content}</span>
<small>{conversation.messages.length} </small>
</button>
</li>
))}
</ol>
)}
</section>
<section className="conversation-detail" role="region" aria-label="当前短信会话">
{!selectedPhone ? (
<div className="conversation-empty-state">
<h2></h2>
<p></p>
</div>
) : (
<>
<header className="conversation-detail-header">
<div>
<small></small>
<h2>{selectedPhone}</h2>
</div>
<span>{instance.name}</span>
</header>
<ol className="message-thread" aria-label={`${selectedPhone} 的短信记录`}>
{activeConversation?.messages.map((message) => (
<li
key={message.id}
className={`message-bubble message-${directionLabel(message.direction)}`}
>
<header>
<strong>
{directionLabel(message.direction)} · {message.phoneNumber}
</strong>
<time>{message.timestamp}</time>
</header>
<p>{message.content}</p>
<small>{message.status}</small>
</li>
))}
</ol>
<form
className="message-composer"
onSubmit={(event) => {
event.preventDefault();
setPendingSend({ phoneNumber: selectedPhone, content: content.trim() });
}}
>
<label>
<textarea
name="content"
value={content}
onChange={(event) => setContent(event.target.value)}
maxLength={1600}
required
/>
</label>
<div className="composer-footer">
<small>{content.length} / 1600 </small>
<button type="submit" disabled={sending || !content.trim()}>
{sending ? '正在发送…' : '发送短信'}
</button>
</div>
</form>
{sendState === 'success' ? <p role="status"></p> : null}
{sendState === 'error' ? (
<p role="alert"></p>
) : null}
{pendingSend ? (
<section className="send-confirmation" role="region" aria-label="确认发送短信">
<h3></h3>
<p>{instance.name}</p>
<p>{pendingSend.phoneNumber}</p>
<p>{pendingSend.content}</p>
<p></p>
<div className="fleet-card-actions">
<button type="button" disabled={sending} onClick={() => void send(pendingSend)}>
{sending ? '正在发送…' : '确认并发送'}
</button>
<button
type="button"
disabled={sending}
onClick={() => setPendingSend(undefined)}
>
</button>
</div>
</section>
) : null}
</>
)}
</section>
{newConversationOpen ? (
<div className="dialog-backdrop">
<section
className="new-conversation-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="new-conversation-title"
>
<h2 id="new-conversation-title"></h2>
<p></p>
<label>
<input
value={newPhoneNumber}
onChange={(event) => setNewPhoneNumber(event.target.value)}
minLength={3}
maxLength={32}
autoFocus
/>
</label>
<div className="fleet-card-actions">
<button
type="button"
disabled={!newPhoneNumber.trim()}
onClick={() => {
conversationLifecycle.current += 1;
setSelectedPhone(newPhoneNumber.trim());
setNewConversationOpen(false);
setNewPhoneNumber('');
setContent('');
setPendingSend(undefined);
setSendState(undefined);
}}
>
</button>
<button type="button" onClick={() => setNewConversationOpen(false)}>
</button>
</div>
</section>
</div>
) : null}
</div>
);
}
+194 -34
View File
@@ -284,6 +284,7 @@ small {
gap: 1rem;
}
.fleet-card {
position: relative;
min-width: 0;
padding: 1rem;
border: 1px solid var(--border);
@@ -588,6 +589,43 @@ tbody th small {
justify-content: flex-end;
padding-top: 1rem;
}
.fleet-card-entry {
display: block;
min-width: 0;
color: inherit;
text-decoration: none;
}
.fleet-card-entry::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
border-radius: var(--radius);
}
.fleet-card-entry:focus-visible {
outline: none;
}
.fleet-card-entry:focus-visible::after {
outline: 3px solid var(--warning);
outline-offset: 3px;
}
.fleet-card-entry:hover h2,
.fleet-card-entry:focus-visible h2 {
color: var(--accent-strong);
text-decoration: underline;
text-underline-offset: 0.2em;
}
.fleet-card-entry code {
display: block;
overflow-wrap: anywhere;
}
.fleet-card-admin-actions {
position: relative;
z-index: 1;
justify-content: flex-end;
padding-top: 0.25rem;
border-top: 1px solid #e9dfca;
}
.instance-detail {
display: grid;
grid-template-columns: 13rem minmax(0, 1fr);
@@ -622,57 +660,171 @@ dd {
.instance-module-detail {
min-width: 0;
}
.messages-module {
.messages-workbench {
display: grid;
grid-template-columns: minmax(16rem, 0.85fr) minmax(22rem, 1.4fr);
align-items: start;
gap: 0.8rem;
grid-template-columns: minmax(16rem, 21rem) minmax(24rem, 1fr);
min-height: 36rem;
overflow: hidden;
border: 1px solid #e2d6bf;
border-radius: var(--radius);
background: var(--surface-raised);
}
.messages-module,
.messages-card,
.sms-list,
.sms-message-card {
.conversation-panel {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
border-right: 1px solid #e2d6bf;
background: #fffdf5;
}
.messages-card h2 {
margin-top: 0;
}
.messages-card form,
.messages-card label {
display: grid;
min-width: 0;
gap: 0.5rem;
}
.messages-card form {
.conversation-panel-header,
.conversation-detail-header,
.conversation-entry-title,
.composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.messages-card textarea {
min-height: 8rem;
.conversation-panel-header,
.conversation-detail-header {
min-height: 4.5rem;
padding: 0.8rem 1rem;
border-bottom: 1px solid #e2d6bf;
}
.conversation-panel-header h2,
.conversation-detail-header h2,
.conversation-panel-header span,
.conversation-detail-header small {
margin: 0;
}
.conversation-panel-header span,
.conversation-detail-header span,
.conversation-entry small,
.conversation-entry time,
.composer-footer small {
color: var(--muted);
font-size: 0.78rem;
}
.conversation-list,
.message-thread {
padding: 0;
margin: 0;
list-style: none;
}
.conversation-entry {
width: 100%;
display: grid;
gap: 0.3rem;
padding: 0.85rem 1rem;
border: 0;
border-bottom: 1px solid #eee4d2;
border-radius: 0;
color: inherit;
background: transparent;
box-shadow: none;
text-align: left;
}
.conversation-entry > span:not(.conversation-entry-title) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.conversation-entry:hover,
.conversation-entry[aria-pressed='true'] {
background: #f2f8e9;
}
.conversation-state,
.conversation-empty-state {
padding: 1.2rem;
}
.conversation-detail {
min-width: 0;
display: flex;
flex-direction: column;
background: #fffaf0;
}
.conversation-empty-state {
margin: auto;
max-width: 24rem;
text-align: center;
color: var(--muted);
}
.message-thread {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.65rem;
max-height: 32rem;
padding: 1rem;
overflow-y: auto;
}
.message-bubble {
max-width: min(78%, 36rem);
padding: 0.75rem 0.9rem;
border: 1px solid #e2d6bf;
border-radius: 14px;
background: #fffdf5;
}
.message-bubble header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.5rem;
}
.message-bubble p {
white-space: pre-wrap;
}
.message-发送 {
align-self: flex-end;
background: #eaf4dd;
}
.message-composer {
display: grid;
gap: 0.6rem;
padding: 0.8rem 1rem;
border-top: 1px solid #e2d6bf;
background: #fffdf5;
}
.message-composer label {
display: grid;
gap: 0.35rem;
}
.message-composer textarea {
box-sizing: border-box;
width: 100%;
min-height: 5rem;
resize: vertical;
}
.send-confirmation {
margin-top: 0.8rem;
margin: 0 1rem 1rem;
padding: 0.8rem;
border: 1px solid #dca66d;
border-radius: 14px;
background: #fff5dc;
}
.send-confirmation h3,
.send-confirmation p {
margin-top: 0;
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(72, 52, 33, 0.36);
}
.messages-card input,
.messages-card textarea {
.new-conversation-dialog {
width: min(100%, 26rem);
padding: 1.2rem;
border: 1px solid #e2d6bf;
border-radius: var(--radius);
background: #fffdf5;
box-shadow: 0 20px 60px rgba(72, 52, 33, 0.24);
}
.new-conversation-dialog label {
display: grid;
gap: 0.4rem;
}
.new-conversation-dialog input {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
width: 100%;
}
.sms-list {
padding-inline-start: 1.25rem;
}
.overview-grid,
.cellular-grid,
.device-network-grid,
@@ -767,8 +919,16 @@ main ul[aria-label='已配置实例'] {
.instance-context {
margin-bottom: 1rem;
}
.messages-module {
.messages-workbench {
grid-template-columns: minmax(0, 1fr);
min-height: 0;
}
.conversation-panel {
border-right: 0;
border-bottom: 1px solid #e2d6bf;
}
.message-thread {
max-height: none;
}
.instance-context ul {
display: grid;