@@ -199,8 +199,8 @@ export function AdminWebChatPilotPanel() {
{!loading && !error && members.length === 0 ? (
) : null}
diff --git a/src/components/admin/admin.css b/src/components/admin/admin.css
index 6dd71bc..ec46a66 100644
--- a/src/components/admin/admin.css
+++ b/src/components/admin/admin.css
@@ -468,6 +468,20 @@
padding: 13px 0;
}
+.admin-member-webchat-toggle.is-readonly {
+ grid-template-columns: 22px minmax(0, 1fr) auto;
+}
+
+.admin-member-webchat-readonly-facts {
+ grid-column: 1 / -1;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ margin: 0;
+ padding: 16px;
+ background: var(--surface-muted);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+}
+
.admin-member-webchat-toggle input {
width: 17px;
height: 17px;
@@ -685,6 +699,9 @@
.admin-member-webchat-form {
grid-template-columns: 1fr;
}
+ .admin-member-webchat-readonly-facts {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
.admin-member-webchat-reason,
.admin-member-webchat-error,
.admin-member-webchat-actions {
diff --git a/src/pages/PrivacyPage.test.tsx b/src/pages/PrivacyPage.test.tsx
index f1ffe85..b1e674e 100644
--- a/src/pages/PrivacyPage.test.tsx
+++ b/src/pages/PrivacyPage.test.tsx
@@ -34,6 +34,10 @@ describe('PrivacyPage', () => {
)
expect(section).toHaveTextContent('管理员默认也不能读取其他成员的对话正文')
expect(section).toHaveTextContent('180 天后自动删除')
+ expect(section).toHaveTextContent('当前关闭态不提供会话浏览或单会话删除入口')
+ expect(section).toHaveTextContent('登录成员仍可在“我的资料”导出本人历史')
+ expect(section).toHaveTextContent('永久注销账号时全部会话和消息随账号级联删除')
+ expect(section).not.toHaveTextContent('随时删除单个会话')
expect(section).toHaveTextContent('不可逆内容指纹')
expect(section).toHaveTextContent('受当时所选服务及其政策约束')
expect(section).toHaveTextContent('功能关闭后不再产生新的模型请求')
diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx
index 6ad7dde..dc84774 100644
--- a/src/pages/PrivacyPage.tsx
+++ b/src/pages/PrivacyPage.tsx
@@ -114,8 +114,8 @@ export function PrivacyPage() {
历史运行期间形成的会话标题、成员问题和模型可见回复仍保存在 Supabase
私有数据库中,并继续受原有保留和删除规则约束。每个账号最多保留 100 个会话,单个会话最多
120 条消息且序列化内容不超过 1 MiB;会话最后活动超过 180
- 天后自动删除,成员也可以随时删除单个会话。历史读取、修改和删除接口只绑定当前登录账号,不接受目标成员
- ID;管理员默认也不能读取其他成员的对话正文。永久注销账号时,全部会话和消息随账号级联删除。
+ 天后自动删除。当前关闭态不提供会话浏览或单会话删除入口;登录成员仍可在“我的资料”导出本人历史,永久注销账号时全部会话和消息随账号级联删除。遗留历史接口只绑定当前登录账号,不接受目标成员
+ ID;管理员默认也不能读取其他成员的对话正文。
遗留额度、并发控制和故障诊断账本保存成员
diff --git a/src/pages/admin/AdminMemberDetailPage.test.tsx b/src/pages/admin/AdminMemberDetailPage.test.tsx
index 131d634..51f1369 100644
--- a/src/pages/admin/AdminMemberDetailPage.test.tsx
+++ b/src/pages/admin/AdminMemberDetailPage.test.tsx
@@ -11,7 +11,6 @@ const memberDetailMocks = vi.hoisted(() => ({
setAccountStatus: vi.fn(),
triggerSync: vi.fn(),
fetchWebChatAccess: vi.fn(),
- updateWebChatAccess: vi.fn(),
}))
vi.mock('../../lib/supabase', () => ({ supabase: { rpc: vi.fn() } }))
@@ -34,7 +33,6 @@ vi.mock('../../lib/webChatMemberAccess', async () => {
return {
...actual,
fetchAdminWebChatMemberAccess: memberDetailMocks.fetchWebChatAccess,
- updateAdminWebChatMemberAccess: memberDetailMocks.updateWebChatAccess,
}
})
@@ -70,13 +68,6 @@ describe('AdminMemberDetailPage', () => {
version: 1,
updatedAt: '2026-07-17T08:00:00Z',
})
- memberDetailMocks.updateWebChatAccess.mockResolvedValue({
- enabled: true,
- totalRequestLimit: 12,
- totalTokenLimit: 50_000,
- version: 2,
- updatedAt: '2026-07-17T09:00:00Z',
- })
})
it('shows the member profile, six platforms, and XCPC automatic matching boundary', async () => {
@@ -111,68 +102,22 @@ describe('AdminMemberDetailPage', () => {
expect(await screen.findByText('牛客 账号已保存,等待验证。')).toBeInTheDocument()
})
- it('lets an administrator enable WebChat and set cumulative member limits', async () => {
- const user = userEvent.setup()
+ it('shows WebChat access as a read-only historical record', async () => {
renderPage()
await screen.findByRole('heading', { name: '沈亦安' })
- const enabled = await screen.findByRole('checkbox', { name: /允许使用 AI 学习助手/ })
- const requestLimit = screen.getByRole('spinbutton', { name: /累计请求总上限/ })
- const tokenLimit = screen.getByRole('spinbutton', { name: /累计 Token 总上限/ })
- await user.click(enabled)
- await user.clear(requestLimit)
- await user.type(requestLimit, '12')
- await user.clear(tokenLimit)
- await user.type(tokenLimit, '50000')
- await user.type(screen.getByRole('textbox', { name: /修改原因/ }), '开放成员权限')
- await user.click(screen.getByRole('button', { name: '保存 AI 助手配置' }))
-
- expect(memberDetailMocks.updateWebChatAccess).toHaveBeenCalledWith({
- memberId: 'member-1',
- enabled: true,
- totalRequestLimit: 12,
- totalTokenLimit: 50_000,
- expectedVersion: 1,
- reason: '开放成员权限',
- })
- expect(await screen.findByText('成员 AI 助手权限与额度已保存。')).toBeInTheDocument()
- expect(screen.getByText('配置版本 v2')).toBeInTheDocument()
- })
-
- it('lets an administrator disable AI access without changing cumulative limits', async () => {
- const user = userEvent.setup()
- memberDetailMocks.fetchWebChatAccess.mockResolvedValue({
- enabled: true,
- totalRequestLimit: 12,
- totalTokenLimit: 50_000,
- version: 4,
- updatedAt: '2026-07-17T08:00:00Z',
- })
- memberDetailMocks.updateWebChatAccess.mockResolvedValue({
- enabled: false,
- totalRequestLimit: 12,
- totalTokenLimit: 50_000,
- version: 5,
- updatedAt: '2026-07-17T09:00:00Z',
- })
- renderPage()
- await screen.findByRole('heading', { name: '沈亦安' })
-
- const access = await screen.findByRole('checkbox', { name: /允许使用 AI 学习助手/ })
- expect(access).toBeChecked()
-
- await user.click(access)
- await user.type(screen.getByRole('textbox', { name: /修改原因/ }), '关闭该账号权限')
- await user.click(screen.getByRole('button', { name: '保存 AI 助手配置' }))
-
- expect(memberDetailMocks.updateWebChatAccess).toHaveBeenCalledWith({
- memberId: 'member-1',
- enabled: false,
- totalRequestLimit: 12,
- totalTokenLimit: 50_000,
- expectedVersion: 4,
- reason: '关闭该账号权限',
- })
+ const section = screen.getByRole('heading', { name: 'AI 助手历史授权' }).closest('section')
+ expect(section).not.toBeNull()
+ expect(within(section as HTMLElement).getByText('产品入口已关闭')).toBeInTheDocument()
+ expect(within(section as HTMLElement).getByText('已关闭')).toBeInTheDocument()
+ expect(within(section as HTMLElement).getByText('10')).toBeInTheDocument()
+ expect(within(section as HTMLElement).getByText('40,000')).toBeInTheDocument()
+ expect(within(section as HTMLElement).getByText('v1')).toBeInTheDocument()
+ expect(within(section as HTMLElement).queryByRole('checkbox')).not.toBeInTheDocument()
+ expect(within(section as HTMLElement).queryByRole('spinbutton')).not.toBeInTheDocument()
+ expect(
+ within(section as HTMLElement).queryByRole('button', { name: /保存/ }),
+ ).not.toBeInTheDocument()
})
it('keeps member platform details available when WebChat access loading fails', async () => {
diff --git a/src/pages/admin/AdminMemberDetailPage.tsx b/src/pages/admin/AdminMemberDetailPage.tsx
index fc6471b..7fb1055 100644
--- a/src/pages/admin/AdminMemberDetailPage.tsx
+++ b/src/pages/admin/AdminMemberDetailPage.tsx
@@ -34,8 +34,6 @@ import { platformLabels, platformUrls } from '../../lib/platforms'
import { supabase } from '../../lib/supabase'
import {
fetchAdminWebChatMemberAccess,
- updateAdminWebChatMemberAccess,
- WebChatMemberAccessConflictError,
type WebChatMemberAccess,
} from '../../lib/webChatMemberAccess'
import type {
@@ -166,12 +164,7 @@ export function AdminMemberDetailPage() {
const [manualNote, setManualNote] = useState('')
const [webChatAccess, setWebChatAccess] = useState(null)
const [webChatAccessLoading, setWebChatAccessLoading] = useState(true)
- const [webChatAccessSaving, setWebChatAccessSaving] = useState(false)
const [webChatAccessError, setWebChatAccessError] = useState('')
- const [webChatEnabled, setWebChatEnabled] = useState(false)
- const [webChatTotalRequestLimit, setWebChatTotalRequestLimit] = useState('30')
- const [webChatTotalTokenLimit, setWebChatTotalTokenLimit] = useState('100000')
- const [webChatReason, setWebChatReason] = useState('')
const dialogRef = useRef(null)
const dialogTriggerRef = useRef(null)
@@ -201,89 +194,25 @@ export function AdminMemberDetailPage() {
void loadMember()
}, [loadMember])
- const applyWebChatAccess = useCallback((access: WebChatMemberAccess) => {
- setWebChatAccess(access)
- setWebChatEnabled(access.enabled)
- setWebChatTotalRequestLimit(String(access.totalRequestLimit))
- setWebChatTotalTokenLimit(String(access.totalTokenLimit))
- setWebChatReason('')
- }, [])
-
const loadWebChatAccess = useCallback(
async (clearError = true) => {
setWebChatAccessLoading(true)
if (clearError) setWebChatAccessError('')
try {
- applyWebChatAccess(await fetchAdminWebChatMemberAccess(memberId))
+ setWebChatAccess(await fetchAdminWebChatMemberAccess(memberId))
} catch (error) {
setWebChatAccessError(error instanceof Error ? error.message : '成员 AI 助手配置读取失败。')
} finally {
setWebChatAccessLoading(false)
}
},
- [applyWebChatAccess, memberId],
+ [memberId],
)
useEffect(() => {
void loadWebChatAccess()
}, [loadWebChatAccess])
- async function saveWebChatAccess(event: FormEvent) {
- event.preventDefault()
- if (!member || !webChatAccess) return
-
- const totalRequestLimit = Number(webChatTotalRequestLimit)
- const totalTokenLimit = Number(webChatTotalTokenLimit)
- const reason = webChatReason.trim()
- if (
- !Number.isSafeInteger(totalRequestLimit) ||
- totalRequestLimit < 1 ||
- totalRequestLimit > 10_000
- ) {
- setWebChatAccessError('累计请求总上限必须是 1 到 10000 之间的整数。')
- return
- }
- if (
- !Number.isSafeInteger(totalTokenLimit) ||
- totalTokenLimit < 100 ||
- totalTokenLimit > 1_000_000_000
- ) {
- setWebChatAccessError('累计 Token 总上限必须是 100 到 1000000000 之间的整数。')
- return
- }
- if (webChatEnabled && member.status !== 'active') {
- setWebChatAccessError('已停用账号不能开启 AI 助手;请先恢复账号。')
- return
- }
- if (reason.length < 3 || reason.length > 500) {
- setWebChatAccessError('请填写 3 到 500 字的修改原因。')
- return
- }
-
- setWebChatAccessSaving(true)
- setWebChatAccessError('')
- try {
- const nextAccess = await updateAdminWebChatMemberAccess({
- memberId: member.id,
- enabled: webChatEnabled,
- totalRequestLimit,
- totalTokenLimit,
- expectedVersion: webChatAccess.version,
- reason,
- })
- applyWebChatAccess(nextAccess)
- setNoticeKind('success')
- setNotice('成员 AI 助手权限与额度已保存。')
- } catch (error) {
- if (error instanceof WebChatMemberAccessConflictError) {
- await loadWebChatAccess(false)
- }
- setWebChatAccessError(error instanceof Error ? error.message : '成员 AI 助手配置保存失败。')
- } finally {
- setWebChatAccessSaving(false)
- }
- }
-
function rememberDialogTrigger() {
const activeElement = document.activeElement
dialogTriggerRef.current = activeElement instanceof HTMLElement ? activeElement : null
@@ -589,15 +518,6 @@ export function AdminMemberDetailPage() {
? `手工录入 ${platformLabels[dialog.item.platform]} 数据`
: `解绑 ${platformLabels[dialog.item.platform]} 账号`
: ''
- const parsedWebChatRequestLimit = Number(webChatTotalRequestLimit)
- const parsedWebChatTokenLimit = Number(webChatTotalTokenLimit)
- const webChatAccessChanged = Boolean(
- webChatAccess &&
- (webChatEnabled !== webChatAccess.enabled ||
- parsedWebChatRequestLimit !== webChatAccess.totalRequestLimit ||
- parsedWebChatTokenLimit !== webChatAccess.totalTokenLimit),
- )
-
return (
@@ -657,13 +577,13 @@ export function AdminMemberDetailPage() {
-
AI 助手访问
-
管理 AI 使用权限和累计额度。
+
AI 助手历史授权
+
只读核对停止前的授权、累计额度与用量。
void loadWebChatAccess()}
>
) : null}
{webChatAccess ? (
-