diff --git a/web/src/AccountList.jsx b/web/src/AccountList.jsx
index 3d3854e..efe8abf 100644
--- a/web/src/AccountList.jsx
+++ b/web/src/AccountList.jsx
@@ -114,7 +114,7 @@ function AccountForm({ busy, onSubmit }) {
update('credential_provider', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用来源' } }}>
- update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 127 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" />
+ update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 192 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" />
)
diff --git a/web/src/AccountList.test.jsx b/web/src/AccountList.test.jsx
index a9b7b21..1606ea5 100644
--- a/web/src/AccountList.test.jsx
+++ b/web/src/AccountList.test.jsx
@@ -45,6 +45,27 @@ describe('AccountList', () => {
expect(screen.queryByLabelText(/密码|Cookie|token/i)).toBeNull()
})
+ it('accepts and submits a 192-character credential reference key', async () => {
+ const dataProvider = provider()
+ const credentialKey = `${'a'.repeat(64)}/${'b'.repeat(127)}`
+ renderList(dataProvider)
+ await screen.findAllByText('shop-a')
+
+ fireEvent.change(screen.getByRole('textbox', { name: '平台' }), { target: { value: 'mock' } })
+ fireEvent.change(screen.getByRole('textbox', { name: '平台账号标识' }), { target: { value: 'shop-new' } })
+ fireEvent.change(screen.getByRole('textbox', { name: '凭据引用 ID' }), { target: { value: 'credential-new' } })
+ const input = screen.getByRole('textbox', { name: '凭据引用键' })
+ fireEvent.change(input, { target: { value: credentialKey } })
+ fireEvent.click(screen.getByRole('button', { name: '创建账号' }))
+
+ expect(credentialKey).toHaveLength(192)
+ expect(input.maxLength).toBe(192)
+ await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('accounts', { data: {
+ platform: 'mock', platform_account_key: 'shop-new', authorization_kind: 'owned',
+ credential_reference: { id: 'credential-new', provider: 'os_keyring', key: credentialKey },
+ } }))
+ })
+
it('distinguishes resumable bindings from missing resources', () => {
expect(accountReadiness(account, binding)).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
expect(accountReadiness(account, undefined)).toMatchObject({ label: '未绑定运行环境', canResume: false })
diff --git a/web/src/BrowserList.jsx b/web/src/BrowserList.jsx
index 09844d1..c2ebf4e 100644
--- a/web/src/BrowserList.jsx
+++ b/web/src/BrowserList.jsx
@@ -60,6 +60,8 @@ const scheduleReasonLabels = {
runtime_missing: '运行实例缺失',
}
+const scheduleBlockLabel = runtime => runtime.schedule_status === 'ready' ? '' : (scheduleReasonLabels[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度')
+
const emptyForm = {
name: '',
alias: '',
@@ -106,9 +108,10 @@ function Status({ state }) {
function RuntimeActions({ runtime, busy, onAction }) {
const running = runtime.state === 'running'
+ const startBlockReason = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlockLabel(runtime)
return (
- } onClick={() => onAction(runtime, 'start')}>启动
+ } onClick={() => onAction(runtime, 'start')}>启动
} onClick={() => onAction(runtime, 'stop')}>停止
} onClick={() => onAction(runtime, 'upgrade')}>升级
} onClick={() => onAction(runtime, 'recycle')}>回收
@@ -258,9 +261,8 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
}
function BindingReadiness({ runtime }) {
- const ready = runtime.schedule_status === 'ready'
- const reason = runtime.schedule_block_reason
- return {ready ? : }{ready ? '可调度' : (scheduleReasonLabels[reason] || reason || '不可调度')}
+ const blockReason = scheduleBlockLabel(runtime)
+ return {blockReason ? : }{blockReason || '可调度'}
}
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
diff --git a/web/src/BrowserList.test.jsx b/web/src/BrowserList.test.jsx
index 16db3e4..443a8f3 100644
--- a/web/src/BrowserList.test.jsx
+++ b/web/src/BrowserList.test.jsx
@@ -81,6 +81,44 @@ describe('BrowserList', () => {
await waitFor(() => expect(dataProvider.browserAction).toHaveBeenCalledWith('account-a', 'stop', undefined))
})
+ it('starts ready or runtime-missing environments and preserves resource gates', async () => {
+ const environments = [
+ { ...runtimes[1], id: 'ready', alias: 'ready', name: '就绪环境', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'ready' },
+ { ...runtimes[1], id: 'stopped', alias: 'stopped', name: '已停止环境', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'blocked', schedule_block_reason: 'runtime_missing' },
+ { ...runtimes[1], id: 'revoked', alias: 'revoked', name: '已撤权环境', schedule_status: 'blocked', schedule_block_reason: 'account_revoked' },
+ { ...runtimes[1], id: 'paused', alias: 'paused', name: '账号暂停环境', schedule_status: 'blocked', schedule_block_reason: 'account_paused' },
+ { ...runtimes[1], id: 'unbound', alias: 'unbound', name: '未绑定环境', schedule_status: 'blocked', schedule_block_reason: 'binding_missing' },
+ { ...runtimes[1], id: 'no-exit', alias: 'no-exit', name: '无出口环境', schedule_status: 'blocked', schedule_block_reason: 'network_exit_missing' },
+ { ...runtimes[1], id: 'unhealthy', alias: 'unhealthy', name: '出口异常环境', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'unhealthy', schedule_status: 'blocked', schedule_block_reason: 'network_exit_unhealthy' },
+ { ...runtimes[1], id: 'cleanup', alias: 'cleanup', name: '清理中环境', schedule_status: 'blocked', schedule_block_reason: 'runtime_stop_pending' },
+ ]
+ const dataProvider = provider({ getList: vi.fn(resource => Promise.resolve({ data: resource === 'browsers' ? environments : [], total: resource === 'browsers' ? environments.length : 0 })) })
+ render()
+
+ fireEvent.click((await screen.findAllByLabelText('启动 就绪环境'))[0])
+ fireEvent.click(screen.getAllByLabelText('启动 已停止环境')[0])
+ for (const [name, reason] of [['已撤权环境', '授权已撤销'], ['账号暂停环境', '账号已暂停'], ['未绑定环境', '绑定缺失'], ['无出口环境', '固定出口缺失'], ['出口异常环境', '固定出口不健康'], ['清理中环境', '停止结果待确认']]) {
+ expect(screen.getAllByLabelText(`启动 ${name}(${reason})`).every(button => button.disabled)).toBe(true)
+ }
+ await waitFor(() => expect(dataProvider.browserAction.mock.calls).toEqual(expect.arrayContaining([
+ ['ready', 'start', undefined],
+ ['stopped', 'start', undefined],
+ ])))
+ })
+
+ it('keeps the backend 409 fallback for stale readiness', async () => {
+ const stale = { ...runtimes[1], account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'ready' }
+ const dataProvider = provider({
+ getList: vi.fn(resource => Promise.resolve({ data: resource === 'browsers' ? [stale] : [], total: resource === 'browsers' ? 1 : 0 })),
+ browserAction: vi.fn().mockRejectedValue(new HttpError('readiness changed', 409)),
+ })
+ render()
+
+ fireEvent.click((await screen.findAllByLabelText('启动 店铺二号'))[0])
+
+ expect((await screen.findByRole('alert')).textContent).toContain('readiness changed')
+ })
+
it('creates an env with an account and healthy network exit without legacy proxy fields', async () => {
const dataProvider = provider()
render()