Merge pull request #622 from ay095411/main

feat(auth): 注册服务协议 - 名称与条款改为后台可配置并同步注册页展示
This commit is contained in:
ddcat666
2026-09-24 19:48:32 +08:00
committed by GitHub
16 changed files with 977 additions and 74 deletions

View File

@@ -47,13 +47,15 @@ type LoginRequest struct {
type PublicAuthSettings struct {
VerificationPolicy
SMSBindingAvailable bool `json:"smsBindingAvailable"`
EmailBindingAvailable bool `json:"emailBindingAvailable"`
FirstUser bool `json:"firstUser"`
RegistrationEnabled bool `json:"registrationEnabled"`
LinuxDOEnabled bool `json:"linuxdoEnabled"`
EmailEnabled bool `json:"emailEnabled"`
EmailCodeRequired bool `json:"emailCodeRequired"`
SMSBindingAvailable bool `json:"smsBindingAvailable"`
EmailBindingAvailable bool `json:"emailBindingAvailable"`
FirstUser bool `json:"firstUser"`
RegistrationEnabled bool `json:"registrationEnabled"`
LinuxDOEnabled bool `json:"linuxdoEnabled"`
EmailEnabled bool `json:"emailEnabled"`
EmailCodeRequired bool `json:"emailCodeRequired"`
AgreementTitle string `json:"agreementTitle,omitempty"`
AgreementContent string `json:"agreementContent,omitempty"`
}
type AuthSessionResult struct {
@@ -76,7 +78,9 @@ func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
return nil, err
}
if count == 0 {
return &PublicAuthSettings{FirstUser: true, RegistrationEnabled: true, LinuxDOEnabled: false}, nil
// 首个管理员同样要看到服务协议,不能因为跳过注册校验就丢失协议字段。
agreementTitle, agreementContent := s.RegistrationAgreement()
return &PublicAuthSettings{FirstUser: true, RegistrationEnabled: true, LinuxDOEnabled: false, AgreementTitle: agreementTitle, AgreementContent: agreementContent}, nil
}
registrationEnabled, err := s.RegistrationEnabled()
if err != nil {
@@ -107,12 +111,24 @@ func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
p.SMSRegistration = p.SMSRegistration && smsRegister
p.EmailRegistration = p.EmailRegistration && emailEnabled
p.SMSAndEmailRegistration = p.SMSAndEmailRegistration && smsRegister && emailEnabled
return &PublicAuthSettings{VerificationPolicy: p, SMSBindingAvailable: smsBind, EmailBindingAvailable: emailEnabled, FirstUser: false, RegistrationEnabled: registrationEnabled, LinuxDOEnabled: s.LinuxDOEnabled(), EmailEnabled: emailEnabled, EmailCodeRequired: p.EmailRegistration || p.SMSAndEmailRegistration}, nil
agreementTitle, agreementContent := s.RegistrationAgreement()
return &PublicAuthSettings{
VerificationPolicy: p,
SMSBindingAvailable: smsBind,
EmailBindingAvailable: emailEnabled,
FirstUser: false,
RegistrationEnabled: registrationEnabled,
LinuxDOEnabled: s.LinuxDOEnabled(),
EmailEnabled: emailEnabled,
EmailCodeRequired: p.EmailRegistration || p.SMSAndEmailRegistration,
AgreementTitle: agreementTitle,
AgreementContent: agreementContent,
}, nil
}
func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
if !req.AcceptedTerms {
return nil, kernel.BadAuthRequest("请先同意影策服务协议")
return nil, kernel.BadAuthRequest("请先同意" + s.AgreementTitleForMessage())
}
username := NormalizeUsername(req.Username)
email := NormalizeEmail(req.Email)

View File

@@ -242,7 +242,7 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO
return nil, kernel.Forbidden("管理员未开放新用户注册")
}
if !state.AcceptedTerms {
return nil, kernel.BadAuthRequest("请先同意影策服务协议")
return nil, kernel.BadAuthRequest("请先同意" + s.AgreementTitleForMessage())
}
user, identity, err = s.createLinuxDOUser(subject, providerUsername, displayName, profileString(profile, setting.EmailField), avatarURL)
if err != nil {

View File

@@ -14,19 +14,27 @@ import (
const registrationSettingKey = "registration"
// RegistrationSettingRequest 的协议字段用指针区分「未提交」和「明确清空」:
// 注册开关单独切换时不带协议字段,必须保留已保存的协议正文。
type RegistrationSettingRequest struct {
Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
AgreementTitle *string `json:"agreementTitle"`
AgreementContent *string `json:"agreementContent"`
}
type PublicRegistrationSetting struct {
Enabled bool `json:"enabled"`
UpdatedBy string `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Enabled bool `json:"enabled"`
AgreementTitle string `json:"agreementTitle"`
AgreementContent string `json:"agreementContent"`
UpdatedBy string `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type registrationSettingValue struct {
Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
AgreementTitle string `json:"agreementTitle"`
AgreementContent string `json:"agreementContent"`
}
func (s *Service) AdminRegistrationSetting(actor *model.User) (*PublicRegistrationSetting, error) {
@@ -44,11 +52,22 @@ func (s *Service) UpdateRegistrationSetting(actor *model.User, req RegistrationS
if err := s.host.RequireAdmin(actor); err != nil {
return nil, err
}
encoded, err := json.Marshal(registrationSettingValue{Enabled: req.Enabled})
current, currentValue, err := s.readRegistrationSetting()
if err != nil {
return nil, err
}
current, _, err := s.readRegistrationSetting()
nextValue := registrationSettingValue{
Enabled: req.Enabled,
AgreementTitle: currentValue.AgreementTitle,
AgreementContent: currentValue.AgreementContent,
}
if req.AgreementTitle != nil {
nextValue.AgreementTitle = strings.TrimSpace(*req.AgreementTitle)
}
if req.AgreementContent != nil {
nextValue.AgreementContent = strings.TrimSpace(*req.AgreementContent)
}
encoded, err := json.Marshal(nextValue)
if err != nil {
return nil, err
}
@@ -59,7 +78,7 @@ func (s *Service) UpdateRegistrationSetting(actor *model.User, req RegistrationS
if err := s.repo.SaveSystemSetting(&setting); err != nil {
return nil, err
}
return publicRegistrationSetting(&setting, registrationSettingValue{Enabled: req.Enabled}), nil
return publicRegistrationSetting(&setting, nextValue), nil
}
func (s *Service) RegistrationEnabled() (bool, error) {
@@ -67,6 +86,39 @@ func (s *Service) RegistrationEnabled() (bool, error) {
return value.Enabled, err
}
// AgreementTitleForMessage 只用于错误提示。协议读取失败时不再返回品牌名兜底标题,
// 否则用户会被要求同意一个后台并不存在的协议名称。
func (s *Service) AgreementTitleForMessage() string {
title, _ := s.RegistrationAgreement()
if strings.TrimSpace(title) == "" {
return "服务协议"
}
return title
}
// RegistrationAgreement 返回展示用的协议标题(已去掉书名号)和条款正文。
// 标题未配置时跟随当前品牌名,保证品牌改名后注册页文案同步。
// 读取失败时返回空标题,由调用方决定是降级展示还是给出失败提示。
func (s *Service) RegistrationAgreement() (string, string) {
_, value, err := s.readRegistrationSetting()
if err != nil {
return "", ""
}
title := strings.Trim(strings.TrimSpace(value.AgreementTitle), "《》")
if title == "" {
title = s.defaultAgreementTitle()
}
return title, value.AgreementContent
}
func (s *Service) defaultAgreementTitle() string {
brand := strings.TrimSpace(s.host.BrandName())
if brand == "" {
brand = DefaultBrandName
}
return brand + "服务协议"
}
func (s *Service) readRegistrationSetting() (*model.SystemSetting, registrationSettingValue, error) {
setting, err := s.repo.SystemSetting(registrationSettingKey)
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -83,7 +135,11 @@ func (s *Service) readRegistrationSetting() (*model.SystemSetting, registrationS
}
func publicRegistrationSetting(setting *model.SystemSetting, value registrationSettingValue) *PublicRegistrationSetting {
result := &PublicRegistrationSetting{Enabled: value.Enabled}
result := &PublicRegistrationSetting{
Enabled: value.Enabled,
AgreementTitle: value.AgreementTitle,
AgreementContent: value.AgreementContent,
}
if setting != nil {
result.UpdatedBy = setting.UpdatedBy
result.CreatedAt = setting.CreatedAt

View File

@@ -0,0 +1,140 @@
package auth
import (
"testing"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestRegistrationAgreementFollowsBrandNameWhenUnset(t *testing.T) {
svc := New(repository.New(newAgreementTestDB(t)), brandHost{name: "星野"}, nil)
title, content := svc.RegistrationAgreement()
if title != "星野服务协议" {
t.Fatalf("default agreement title = %q", title)
}
if content != "" {
t.Fatalf("default agreement content = %q", content)
}
settings, err := svc.PublicAuthSettings()
if err != nil {
t.Fatal(err)
}
if settings.AgreementTitle != "星野服务协议" || settings.AgreementContent != "" {
t.Fatalf("public settings agreement = %q / %q", settings.AgreementTitle, settings.AgreementContent)
}
}
func TestUpdateRegistrationSettingStoresAgreement(t *testing.T) {
db := newAgreementTestDB(t)
svc := New(repository.New(db), brandHost{name: "星野"}, nil)
if err := db.Create(&model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}).Error; err != nil {
t.Fatal(err)
}
admin := &model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
saved, err := svc.UpdateRegistrationSetting(admin, RegistrationSettingRequest{
Enabled: true,
AgreementTitle: strPtr("《星野平台服务条款》"),
AgreementContent: strPtr("第一条 服务说明\n\n第二条 内容合规"),
})
if err != nil {
t.Fatal(err)
}
if !saved.Enabled || saved.AgreementTitle != "《星野平台服务条款》" {
t.Fatalf("saved setting = %#v", saved)
}
reloaded, err := svc.AdminRegistrationSetting(admin)
if err != nil {
t.Fatal(err)
}
if reloaded.AgreementContent != "第一条 服务说明\n\n第二条 内容合规" {
t.Fatalf("reloaded content = %q", reloaded.AgreementContent)
}
// 展示用标题去掉书名号,避免注册页出现双书名号。
title, content := svc.RegistrationAgreement()
if title != "星野平台服务条款" || content != "第一条 服务说明\n\n第二条 内容合规" {
t.Fatalf("agreement = %q / %q", title, content)
}
settings, err := svc.PublicAuthSettings()
if err != nil {
t.Fatal(err)
}
if settings.AgreementTitle != "星野平台服务条款" || settings.AgreementContent != "第一条 服务说明\n\n第二条 内容合规" {
t.Fatalf("public settings agreement = %q / %q", settings.AgreementTitle, settings.AgreementContent)
}
}
func TestRegistrationToggleKeepsSavedAgreement(t *testing.T) {
db := newAgreementTestDB(t)
svc := New(repository.New(db), brandHost{name: "星野"}, nil)
admin := &model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
if _, err := svc.UpdateRegistrationSetting(admin, RegistrationSettingRequest{Enabled: true, AgreementContent: strPtr("条款正文")}); err != nil {
t.Fatal(err)
}
enabled, err := svc.RegistrationEnabled()
if err != nil || !enabled {
t.Fatalf("RegistrationEnabled() = %v, %v", enabled, err)
}
// 管理面板切换注册开关时只提交 enabled,已保存的协议正文不能被清空。
switched, err := svc.UpdateRegistrationSetting(admin, RegistrationSettingRequest{Enabled: false})
if err != nil {
t.Fatal(err)
}
if switched.Enabled || switched.AgreementContent != "条款正文" {
t.Fatalf("toggle wiped agreement: %#v", switched)
}
cleared, err := svc.UpdateRegistrationSetting(admin, RegistrationSettingRequest{Enabled: true, AgreementContent: strPtr("")})
if err != nil {
t.Fatal(err)
}
if cleared.AgreementContent != "" {
t.Fatalf("explicit clear ignored: %#v", cleared)
}
}
func strPtr(value string) *string { return &value }
// 配置损坏时不能再返回品牌名兜底标题:注册页会据此展示,与后台真实配置不符。
func TestRegistrationAgreementDegradesWhenSettingUnreadable(t *testing.T) {
db := newAgreementTestDB(t)
svc := New(repository.New(db), brandHost{name: "星野"}, nil)
if err := db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: "{not-json"}).Error; err != nil {
t.Fatal(err)
}
title, content := svc.RegistrationAgreement()
if title != "" || content != "" {
t.Fatalf("broken setting should degrade to empty, got %q / %q", title, content)
}
// 错误提示仍需给出可读文案,但不能是凭空拼出来的协议名。
if message := svc.AgreementTitleForMessage(); message != "服务协议" {
t.Fatalf("fallback message title = %q", message)
}
}
func newAgreementTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+kernel.NewID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.SystemSetting{}); err != nil {
t.Fatal(err)
}
return db
}

View File

@@ -69,11 +69,15 @@ CANVAS_BACKEND_DATA_DIR=/明确的数据目录 ./migrate-schema repair-canvas-re
## 注册服务协议
`POST /auth/register` 的 JSON 请求必须携带布尔值 `acceptedTerms: true`,首次创建管理员也适用。缺省、`false` 或 `null` 均按未同意处理,在创建用户、消费验证码和创建会话前返回 HTTP 400,提示“请先同意影策服务协议”。原有注册开放、邮箱白名单、验证码与限流校验继续生效。
`POST /auth/register` 的 JSON 请求必须携带布尔值 `acceptedTerms: true`,首次创建管理员也适用。缺省、`false` 或 `null` 均按未同意处理,在创建用户、消费验证码和创建会话前返回 HTTP 400,提示“请先同意<协议名称>”。原有注册开放、邮箱白名单、验证码与限流校验继续生效。
`GET /auth/linuxdo/start` 可携带 `acceptedTerms=true`,只有该精确值表示同意。服务端将标志写入一次性的 OAuth state;回调仅在新建 Linux.do 用户时要求该 state 已同意,不从回调参数重新取值。已有 Linux.do 用户可从登录页继续登录,无需传此参数;同意协议不绕过注册关闭策略。
注册页的协议名称为《影策服务协议》。正文暂留空,由维护者在 `web/src/pages/auth/register.tsx` 的 `SERVICE_AGREEMENT` 中手动填写正式条款并审核后再发布;占位提示不是正式协议。当前只校验本次注册的同意标志,不保存长期协议版本或签署凭证。前后端需一起更新,旧客户端缺少同意标志时无法注册。
协议名称与正文由管理员在 `PATCH /admin/settings/registration` 配置,公开读取面是 `GET /auth/settings` 的 `agreementTitle`、`agreementContent`。`agreementTitle` 未配置时按当前外观品牌名生成“<品牌名>服务协议”,因此外观改名后注册页文案同步;接口返回的标题不含书名号,由注册页补《》。`agreementContent` 支持多段文本,按空行分段渲染,留空时注册页提示“服务协议内容待补充”。
读取注册配置失败(如 JSON 损坏)时,`RegistrationAgreement` 返回空标题而不是品牌名兜底标题,注册页据此隐藏协议入口并提示可重新读取,避免展示后台并不存在的协议名称;此时错误提示统一回落为“请先同意服务协议”。
`PATCH /admin/settings/registration` 的两个协议字段为可选指针语义:不提交表示保留已保存内容,提交空串表示明确清空。注册开关与协议正文可以分开保存,单独切换 `enabled` 不会覆盖协议。当前只校验本次注册的同意标志,不保存长期协议版本或签署凭证。前后端需一起更新,旧客户端缺少同意标志时无法注册。
## 站点皮肤主按钮
### 画布配置与 Live2D(外观合同版本 9)

View File

@@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 注册服务协议后台配置
- 已实现:协议名称与条款正文由后台「登录与注册」第 3 区块配置,经 `PATCH /admin/settings/registration` 保存,公开下发在 `GET /auth/settings` 的 `agreementTitle` / `agreementContent`。标题未配置时跟随外观品牌名生成;正文按空行分段渲染,留空时注册页提示待补充。注册开关单独切换不会覆盖协议(后端按可选指针字段合并)。
- [ ] 在真实后台改品牌名与协议正文后,用未登录浏览器打开注册页,确认勾选框与弹窗标题、正文段落在明暗主题下正确显示;再确认协议留空时的占位提示与开关关闭时的注册拦截文案。
- [ ] 用管理员账号验证「注册开关」与「服务协议」两次保存互不覆盖,撤销与离开页面提醒不丢内容;未确认服务端并发写入与多管理员同时编辑的冲突表现。
## 工作区 100% 缩放下的界面密度
- 已收紧用户工作区侧栏、顶栏和导航行、画布桌面端 Dock、Agent 内边距及默认浮窗尺寸;Agent 旧的手动调整尺寸仍保留,可点标题栏「恢复 Agent 紧凑窗口」自行重置。技能组合标签维持透明底和彩色描边。

View File

@@ -2,6 +2,46 @@ import { useEffect, useRef, useState } from "react";
import { App, Button, Input } from "antd";
import { startVerification, type VerificationDraft, type VerificationMethod, type VerificationPurpose } from "@/services/api/verification";
// 验证码输入框与「获取验证码」按钮同排显示:按钮贴右侧、竖线分隔。
// 倒计时文案长度变化会撑动按钮宽度,因此按钮用 tabular-nums + nowrap 固定视觉节奏,
// 输入框通过 flex: 1 吸收剩余宽度,避免每次倒计时跳一下布局。
function CodeField({ label, value, onChange, onSend, sending, remaining, targetReady, disabled, placeholder, autoComplete, inputMode, sendLabel }: {
label: string; value: string; onChange: (next: string) => void;
onSend: () => void; sending: boolean; remaining: number; targetReady: boolean;
disabled: boolean; placeholder: string; autoComplete: string; inputMode: "text" | "email" | "tel" | "numeric"; sendLabel: string;
}) {
const counting = remaining > 0;
const blocked = disabled || sending || counting || !targetReady;
return (
<label className="block space-y-2">
<span className="auth-scene-label text-xs font-medium">{label}</span>
<span className={`auth-code-field${sending ? " is-busy" : ""}`}>
<Input
size="large"
variant="borderless"
value={value}
onChange={(event) => onChange(event.target.value)}
autoComplete={autoComplete}
inputMode={inputMode}
placeholder={placeholder}
disabled={disabled || sending}
/>
<Button
className="auth-code-send"
size="large"
type="text"
data-counting={counting ? "true" : "false"}
loading={sending}
disabled={blocked}
onClick={onSend}
>
{counting ? `${remaining} 秒后重发` : sendLabel}
</Button>
</span>
</label>
);
}
export function VerificationFields({ purpose, method, value, onChange, disabled = false, password }: {
purpose: VerificationPurpose; method: VerificationMethod; value: VerificationDraft;
onChange: (value: VerificationDraft) => void; disabled?: boolean; password?: string;
@@ -34,12 +74,32 @@ export function VerificationFields({ purpose, method, value, onChange, disabled
} catch (error) { message.error(error instanceof Error ? error.message : "发送失败,请稍后重试"); }
finally { setSending(false); inFlight.current = false; }
};
const sendLabel = method === "sms_email" ? "获取验证码" : "获取验证码";
return <div className="space-y-4">
{sms && <label className="block space-y-2"><span className="text-sm">手机号</span><Input size="large" value={value.phone} onChange={(e) => changeTarget("phone", e.target.value)} autoComplete="tel" inputMode="tel" placeholder="中国大陆手机号,支持 +86" required disabled={disabled || sending} /></label>}
{email && <label className="block space-y-2"><span className="text-sm">邮箱</span><Input size="large" type="email" value={value.email} onChange={(e) => changeTarget("email", e.target.value)} autoComplete="email" placeholder="请输入邮箱" required disabled={disabled || sending} /></label>}
<Button block size="large" loading={sending} disabled={disabled || remaining > 0} onClick={() => void send()}>{remaining > 0 ? `${remaining} 秒后可重新获取` : method === "sms_email" ? "获取短信及邮件验证码" : "获取验证码"}</Button>
{sms && <label className="block space-y-2"><span className="text-sm">短信验证码</span><Input size="large" value={value.smsCode} onChange={(e) => onChange({ ...value, smsCode: e.target.value.replace(/\D/g, "").slice(0, 6) })} autoComplete="one-time-code" inputMode="numeric" placeholder="6 位短信验证码" required disabled={disabled || sending} /></label>}
{email && <label className="block space-y-2"><span className="text-sm">邮件验证码</span><Input size="large" value={value.emailCode} onChange={(e) => onChange({ ...value, emailCode: e.target.value.replace(/\D/g, "").slice(0, 6) })} autoComplete="one-time-code" inputMode="numeric" placeholder="6 位邮件验证码" required disabled={disabled || sending} /></label>}
{method === "sms_email" && <p className="text-xs opacity-65">两项验证必须在本次注册中同时通过;修改联系方式后需要重新获取。</p>}
{sms && <label className="block space-y-2"><span className="auth-scene-label text-xs font-medium">手机号</span><Input size="large" value={value.phone} onChange={(e) => changeTarget("phone", e.target.value)} autoComplete="tel" inputMode="tel" placeholder="中国大陆手机号,支持 +86" required disabled={disabled || sending} /></label>}
{email && <label className="block space-y-2"><span className="auth-scene-label text-xs font-medium">邮箱</span><Input size="large" type="email" value={value.email} onChange={(e) => changeTarget("email", e.target.value)} autoComplete="email" placeholder="请输入邮箱" required disabled={disabled || sending} /></label>}
{/* 双通道(sms_email)一次请求同时下发两种验证码,此时按钮文案不区分通道。 */}
{(sms || email) && (
<CodeField
label={method === "sms_email" ? "验证码" : sms ? "短信验证码" : "邮件验证码"}
value={method === "sms_email" ? value.smsCode : sms ? value.smsCode : value.emailCode}
onChange={(next) => {
const code = next.replace(/\D/g, "").slice(0, 6);
if (method === "sms_email" || sms) onChange({ ...value, smsCode: code, ...(method === "sms_email" ? { emailCode: code } : {}) });
else onChange({ ...value, emailCode: code });
}}
onSend={() => void send()}
sending={sending}
remaining={remaining}
// 按钮在联系方式为空时保持禁用,避免用户点了才发现没填邮箱/手机号。
targetReady={(sms ? Boolean(value.phone.trim()) : true) && (email ? Boolean(value.email.trim()) : true) && (purpose !== "bind" || Boolean(password))}
disabled={disabled}
placeholder="6 位数字验证码"
autoComplete="one-time-code"
inputMode="numeric"
sendLabel={sendLabel}
/>
)}
{method === "sms_email" && <p className="auth-scene-muted m-0 text-xs leading-5">两项验证必须在本次注册中同时通过;修改联系方式后需要重新获取。</p>}
</div>;
}

View File

@@ -100,7 +100,7 @@ export function CreditOperationsPage() {
export function AccessSettingsPage() {
return (
<AdminPageFrame title="登录与注册" description="先控制账号创建,再配置第三方登录入口" scroll>
<AdminPageFrame title="登录与注册" description="先控制账号创建,再配置第三方登录入口与服务协议" scroll>
<AccessSettingsPanel />
</AdminPageFrame>
);

View File

@@ -1,25 +1,49 @@
import { App, Button, Form, Input, Select, Skeleton } from "antd";
import { Switch } from "@/pages/admin/ui/controls";
import { AlertTriangle, BadgeCheck, ChevronDown, Globe2, KeyRound, LockKeyhole, RefreshCw, RotateCcw, Save, ShieldCheck, UserPlus, UsersRound } from "lucide-react";
import { AlertTriangle, BadgeCheck, ChevronDown, FileText, Globe2, KeyRound, LockKeyhole, RefreshCw, RotateCcw, Save, ShieldCheck, Sparkles, UserPlus, UsersRound } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useBlocker } from "react-router";
import { cn } from "@/lib/utils";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { getAdminLinuxDOSetting, getAdminRegistrationSetting, updateAdminLinuxDOSetting, updateAdminRegistrationSetting, type LinuxDOSetting, type RegistrationSetting } from "@/services/api/wallet";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
const DEFAULT_AGREEMENT_TEMPLATE = `一、服务说明与接受
欢迎使用本平台提供的 AI 影视与内容创作服务。在注册或使用本平台各项功能前,请务必仔细阅读并理解本协议。当您勾选同意或点击注册、登录按钮,即表示您已自愿接受本协议所有条款的约束。
二、账号注册与安全
1. 用户在注册时应提供真实、准确、合法的个人信息,并自行妥善保管账号密码及相关凭据。
2. 任何通过该账号进行的行为均视为用户本人的行为,因保管不当造成的损失由用户自行承担。
三、内容合规与用户承诺
1. 用户使用本平台生成、上传或发布的文本、图片、视频、音频等所有内容,必须遵守国家相关法律法规及公序良俗。
2. 严禁利用本平台从事任何违反国家法律、侵害他人合法权益(包括但不限于肖像权、名誉权、知识产权、个人隐私等)或危害网络安全的活动。
四、知识产权与免责声明
1. 用户利用本平台创作产生的合法作品归用户所有或依法享有相应权利。
2. AI 模型生成的内容受算法模型与提示词影响,平台对其完整性、准确性不作绝对保证,用户应自行审慎评估和使用。
五、服务变更与终止
平台有权根据业务发展、技术升级或合规要求适时调整服务内容及本协议条款。修改后的条款一经发布即发生效力。`;
type LinuxDOFormValues = Omit<LinuxDOSetting, "hasClientSecret" | "updatedAt">;
export default function AccessSettingsPanel() {
const { message, modal } = App.useApp();
const brandName = useAppearanceStore((state) => state.appearance.brandName) || "平台";
const [linuxdo, setLinuxdo] = useState<LinuxDOSetting | null>(null);
const [registration, setRegistration] = useState<RegistrationSetting | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [savingLinuxDO, setSavingLinuxDO] = useState(false);
const [savingRegistration, setSavingRegistration] = useState(false);
const [savingAgreement, setSavingAgreement] = useState(false);
const [dirty, setDirty] = useState(false);
const [draftLinuxDOEnabled, setDraftLinuxDOEnabled] = useState(false);
const [agreementTitle, setAgreementTitle] = useState("");
const [agreementContent, setAgreementContent] = useState("");
const [agreementDirty, setAgreementDirty] = useState(false);
const [loadError, setLoadError] = useState("");
const [saveError, setSaveError] = useState("");
const [form] = Form.useForm<LinuxDOFormValues>();
@@ -38,6 +62,9 @@ export default function AccessSettingsPanel() {
if (requestVersion !== requestVersionRef.current) return;
setLinuxdo(linuxdoData.setting);
setRegistration(registrationData.setting);
setAgreementTitle(registrationData.setting.agreementTitle || "");
setAgreementContent(registrationData.setting.agreementContent || "");
setAgreementDirty(false);
setDirty(false);
setSaveError("");
if (announce) message.success("已重新读取当前登录与注册配置");
@@ -69,24 +96,29 @@ export default function AccessSettingsPanel() {
setDraftLinuxDOEnabled(linuxdo.enabled);
}, [form, linuxdo, loading, registration]);
const blocker = useBlocker(dirty && !savingLinuxDO);
const unsaved = (dirty && !savingLinuxDO) || (agreementDirty && !savingAgreement);
const blocker = useBlocker(unsaved);
// 条款编辑区下方的字数与段落提示:与注册页的分段规则保持一致(按空行分段),
// 让管理员在保存前就能看到条款会被拆成几段。
const agreementParagraphsCount = agreementContent.trim() ? agreementContent.trim().split(/\n\s*\n/).length : 0;
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty || savingLinuxDO) return;
if (!unsaved) return;
event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty, savingLinuxDO]);
}, [unsaved]);
useEffect(() => {
if (blocker.state !== "blocked" || navigationConfirmOpenRef.current) return;
navigationConfirmOpenRef.current = true;
navigationTriggerRef.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
modal.confirm({
title: "放弃 Linux.do 登录调整?",
content: "当前表单有尚未保存的调整,离开后这些内容会丢失。用户注册状态不受影响。",
title: "放弃未保存的登录与注册调整?",
content: "当前有尚未保存的表单调整,离开后这些内容会丢失。用户注册状态不受影响。",
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
@@ -118,14 +150,46 @@ export default function AccessSettingsPanel() {
message.info("已撤销 Linux.do 登录的未保存调整");
};
const saveAgreement = async () => {
if (!registration || savingAgreement) return;
setSavingAgreement(true);
setSaveError("");
try {
const data = await updateAdminRegistrationSetting({
enabled: registration.enabled,
agreementTitle: agreementTitle.trim(),
agreementContent: agreementContent.trim(),
});
setRegistration(data.setting);
setAgreementTitle(data.setting.agreementTitle || "");
setAgreementContent(data.setting.agreementContent || "");
setAgreementDirty(false);
message.success("服务协议已保存,注册页将立即生效");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存服务协议失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
message.error(errorMessage);
} finally {
setSavingAgreement(false);
}
};
const resetAgreementDraft = () => {
if (!registration || savingAgreement) return;
setAgreementTitle(registration.agreementTitle || "");
setAgreementContent(registration.agreementContent || "");
setAgreementDirty(false);
message.info("已撤销服务协议的未保存调整");
};
const requestRefresh = () => {
if (!dirty) {
if (!dirty && !agreementDirty) {
void load(false, true);
return;
}
modal.confirm({
title: "放弃调整并重新读取?",
content: "重新读取会丢弃当前 Linux.do 表单中的未保存内容,并以服务端配置为准。",
content: "重新读取会丢弃当前表单中的未保存内容(含服务协议),并以服务端配置为准。",
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
@@ -418,6 +482,103 @@ export default function AccessSettingsPanel() {
</Form>
</SettingsSectionCard>
</div>
<div id="admin-access-agreement" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-access-section admin-access-agreement-section"
icon={<FileText className="size-4" aria-hidden="true" />}
title="3. 服务协议名称与条款"
description="注册页勾选框与弹窗展示的内容。协议名称留空时自动跟随品牌名生成,条款留空时注册页给出待补充提示。"
status={<AdminStatusBadge label={agreementDirty ? "待保存" : agreementContent.trim() ? "已自定义" : "待补充"} tone={agreementDirty ? "warning" : agreementContent.trim() ? "success" : "neutral"} />}
footer={
<>
<div className="admin-access-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>保存后注册页立即生效,无需重启服务</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{agreementDirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={savingAgreement} onClick={resetAgreementDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={savingAgreement} disabled={!agreementDirty || loading || refreshing} onClick={() => void saveAgreement()}>
保存服务协议
</Button>
</div>
</>
}
>
<Form layout="vertical" requiredMark={false} disabled={loading || refreshing || savingAgreement}>
<Form.Item
label="协议名称"
extra={`留空时自动跟随品牌名生成,即《${brandName}服务协议》。此处只需填名称本身,书名号由注册页补充。`}
className="admin-access-agreement-title-field"
>
<Input
value={agreementTitle}
maxLength={60}
placeholder={`${brandName}服务协议`}
onChange={(event) => {
setAgreementTitle(event.target.value);
setAgreementDirty(true);
}}
/>
</Form.Item>
<Form.Item
label="协议条款内容"
extra="支持多段落文本,按空行分段展示;留空时注册页弹窗显示“服务协议内容待补充”。"
className="admin-access-agreement-content-field"
>
<Input.TextArea
value={agreementContent}
maxLength={20000}
autoSize={{ minRows: 8, maxRows: 20 }}
placeholder="输入服务协议条款正文"
onChange={(event) => {
setAgreementContent(event.target.value);
setAgreementDirty(true);
}}
/>
</Form.Item>
<div className="admin-access-agreement-toolbar">
<div className="admin-access-agreement-template-note">
<Sparkles className="size-3.5 shrink-0" aria-hidden="true" />
<span>没有准备条款时,可先填入通用模板再按需修改。</span>
</div>
<div className="admin-access-agreement-template-actions">
<span className="admin-access-agreement-metric">
{agreementContent.trim() ? `${agreementParagraphsCount} 段 · ${agreementContent.length} 字` : "尚未填写条款"}
</span>
<Button
size="small"
disabled={savingAgreement || loading || refreshing}
onClick={() => {
setAgreementContent(DEFAULT_AGREEMENT_TEMPLATE);
setAgreementDirty(true);
}}
>
填入通用模板
</Button>
{agreementContent.trim() ? (
<Button
size="small"
type="text"
danger
disabled={savingAgreement || loading || refreshing}
onClick={() => {
setAgreementContent("");
setAgreementDirty(true);
}}
>
清空
</Button>
) : null}
</div>
</div>
</Form>
</SettingsSectionCard>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { type FormEvent, useEffect, useRef, useState, type ReactNode } from "react";
import { App, Button, Checkbox, Divider, Input, Modal, Segmented } from "antd";
import { ArrowRight, Info, LockKeyhole, Mail, TriangleAlert, UserRound } from "lucide-react";
import { ArrowRight, FileText, Info, LockKeyhole, Mail, TriangleAlert, UserRound } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, register } from "@/services/api/auth";
@@ -8,16 +8,15 @@ import { LinuxDOIcon } from "./auth-scene";
import { ApiError } from "@/services/api/request";
import { VerificationFields } from "@/components/auth/verification-fields";
import { emptyVerification, methodLabels, verificationMethods, type VerificationMethod } from "@/services/api/verification";
import { useAppearanceStore } from "@/stores/use-appearance-store";
type AuthSettings = Awaited<ReturnType<typeof getAuthSettings>>;
// TODO: 由维护者填写正式的影策服务协议条款后再发布。
const SERVICE_AGREEMENT: { title: string; content: string }[] = [];
export default function RegisterPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const { message } = App.useApp();
const brandName = useAppearanceStore((state) => state.appearance.brandName) || "平台";
const [settings, setSettings] = useState<AuthSettings | null>(null);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
@@ -28,26 +27,43 @@ export default function RegisterPage() {
const [confirmPassword, setConfirmPassword] = useState("");
const [agreementAccepted, setAgreementAccepted] = useState(false);
const [agreementOpen, setAgreementOpen] = useState(false);
const [settingsFailed, setSettingsFailed] = useState(false);
const [settingsReloadKey, setSettingsReloadKey] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [registerCountdown, setRegisterCountdown] = useState(0);
const registering = useRef(false);
const next = safeNext(params.get("next"));
// 协议标题与条款由后台「登录与注册」配置下发。标题未配置时才回退到当前品牌名,
// 且只在确认拿到设置(settings 非空)后回退;接口失败时不猜标题,避免展示
// 与后台真实配置不符的协议名。
// 标题统一去掉书号后由页面补《》,避免后台已填《》时出现双书名号。
const agreementTitle = ((settings?.agreementTitle || "").trim() || `${brandName}服务协议`).replace(/^《|》$/g, "");
const agreementContent = (settings?.agreementContent || "").trim();
const agreementParagraphs = agreementContent ? agreementContent.split(/\n\s*\n/) : [];
useEffect(() => {
let cancelled = false;
setSettingsFailed(false);
void getAuthSettings()
.then((value) => { if (!cancelled) { setSettings(value); setMethod(verificationMethods(value, "register")[0] ?? "email"); } })
.catch((error) => !cancelled && message.error(error instanceof Error ? error.message : "读取注册设置失败"));
.catch((error) => {
if (cancelled) return;
// 这里必须留下失败态:协议标题只能来自后台配置,读不到时不能用品牌名
// 顶替,否则用户看到的是一个后台并不存在的协议名称。
setSettingsFailed(true);
message.error(error instanceof Error ? error.message : "读取注册设置失败");
});
return () => {
cancelled = true;
};
}, [message]);
}, [message, settingsReloadKey]);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (registering.current || registerCountdown > 0) return;
if (!agreementAccepted) {
message.warning("请先同意影策服务协议");
message.warning(`请先同意${agreementTitle}`);
return;
}
if (password !== confirmPassword) {
@@ -154,14 +170,27 @@ export default function RegisterPage() {
</AuthField>
</div>
<div className="auth-scene-muted flex items-start gap-2 text-xs leading-5">
<Checkbox checked={agreementAccepted} onChange={(event) => setAgreementAccepted(event.target.checked)}>
<span className="auth-scene-muted text-xs leading-5">我已阅读并同意</span>
</Checkbox>
<button type="button" className="auth-scene-link -ml-1 text-xs leading-5 transition" onClick={() => setAgreementOpen(true)}>
《影策服务协议》
</button>
</div>
{settingsFailed ? (
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
<span>
读取注册设置失败,暂时无法确认服务协议名称与条款。
<button type="button" className="auth-scene-link ml-1 transition" onClick={() => setSettingsReloadKey((value) => value + 1)}>
重新读取
</button>
</span>
</Notice>
) : null}
{settings ? (
<div className="auth-agreement-row" data-accepted={agreementAccepted ? "true" : "false"}>
<Checkbox checked={agreementAccepted} onChange={(event) => setAgreementAccepted(event.target.checked)}>
<span className="auth-agreement-label">我已阅读并同意</span>
</Checkbox>
<button type="button" className="auth-agreement-link" onClick={() => setAgreementOpen(true)}>
《{agreementTitle}》
</button>
</div>
) : null}
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled || registerCountdown > 0 || !agreementAccepted} icon={<ArrowRight className="size-4" />} iconPlacement="end">
{registerCountdown > 0 ? `${registerCountdown} 秒后可重试` : "创建账号"}
@@ -176,16 +205,38 @@ export default function RegisterPage() {
</Button>
</>
) : null}
<Modal className="workspace-modal workspace-modal-compact" title="影策服务协议" open={agreementOpen} onCancel={() => setAgreementOpen(false)} footer={null} destroyOnHidden>
<div className="max-h-96 space-y-4 overflow-y-auto pr-1 text-sm leading-6 text-foreground/68">
{SERVICE_AGREEMENT.length === 0 ? <p className="m-0">服务协议内容待补充。</p> : null}
{SERVICE_AGREEMENT.map((item) => (
<section key={item.title}>
<h3 className="m-0 text-sm font-semibold text-foreground">{item.title}</h3>
<p className="mt-1.5 mb-0">{item.content}</p>
</section>
))}
</div>
<Modal
className="workspace-modal workspace-modal-compact auth-agreement-modal"
title={agreementTitle}
open={agreementOpen}
onCancel={() => setAgreementOpen(false)}
footer={null}
destroyOnHidden
>
{agreementParagraphs.length === 0 ? (
<div className="auth-agreement-empty">
<FileText className="size-3.5 shrink-0" aria-hidden />
服务协议内容待补充,请联系管理员在后台「登录与注册」中完善。
</div>
) : (
<div className="auth-agreement-body">
{agreementParagraphs.map((paragraph, index) => {
// 后台用纯文本框维护条款,「一、二、三…」条目通常和正文写在同一个
// 段落里(只用一个换行分隔)。这里把条目标题切出来单独成行并加重,
// 否则标题和正文会挤在同一行,长条款失去可扫描的层级。
const blocks = splitAgreementParagraph(paragraph);
return blocks.map((block, blockIndex) => (
<p key={`agreement-${index}-${blockIndex}`} data-role={block.heading ? "heading" : undefined}>
{block.text}
</p>
));
})}
</div>
)}
<p className="auth-agreement-meta">
<LockKeyhole className="size-3 shrink-0" aria-hidden />
继续注册即表示你已阅读并接受本协议全部条款。
</p>
</Modal>
</form>
);
@@ -213,3 +264,20 @@ function safeNext(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/";
return value;
}
// 中文条款里「一、」「二、」这类条目标题常与正文写在同一个段落内(只隔一个换行)。
// 这里按「条目标题 + 紧随其后的正文」拆成独立段落,让标题能单独成行并加重;
// 没有条目标题的普通段落原样返回,不改变后台已经整理好的排版。
const AGREEMENT_HEADING = /^\s*([一二三四五六七八九十百]+、[^\n]{0,40})\n+([\s\S]+)$/;
function splitAgreementParagraph(paragraph: string): { text: string; heading: boolean }[] {
const match = paragraph.match(AGREEMENT_HEADING);
if (!match) {
const headingOnly = /^\s*[一二三四五六七八九十百]+、[^\n]{0,40}\s*$/.test(paragraph);
return [{ text: paragraph.trim(), heading: headingOnly }];
}
return [
{ text: match[1].trim(), heading: true },
{ text: match[2].trim(), heading: false },
];
}

View File

@@ -378,7 +378,7 @@ export type RuntimePolicySetting = {
};
export function getAuthSettings() {
return http.get<VerificationPolicy & { firstUser: boolean; registrationEnabled: boolean; linuxdoEnabled: boolean; emailEnabled: boolean; emailCodeRequired: boolean; smsBindingAvailable: boolean; emailBindingAvailable: boolean }>("/auth/settings");
return http.get<VerificationPolicy & { firstUser: boolean; registrationEnabled: boolean; linuxdoEnabled: boolean; emailEnabled: boolean; emailCodeRequired: boolean; smsBindingAvailable: boolean; emailBindingAvailable: boolean; agreementTitle?: string; agreementContent?: string }>("/auth/settings");
}
export function linuxDOLoginURL(next: string, acceptedTerms?: boolean) {

View File

@@ -148,6 +148,8 @@ export type LinuxDOSetting = {
export type RegistrationSetting = {
enabled: boolean;
agreementTitle?: string;
agreementContent?: string;
updatedBy?: string;
createdAt?: string;
updatedAt?: string;
@@ -274,8 +276,9 @@ export function getAdminRegistrationSetting() {
return http.get<{ setting: RegistrationSetting }>("/admin/settings/registration");
}
export function updateAdminRegistrationSetting(enabled: boolean) {
return http.patch<{ setting: RegistrationSetting }>("/admin/settings/registration", { enabled });
export function updateAdminRegistrationSetting(input: { enabled: boolean; agreementTitle?: string; agreementContent?: string } | boolean) {
const payload = typeof input === "boolean" ? { enabled: input } : input;
return http.patch<{ setting: RegistrationSetting }>("/admin/settings/registration", payload);
}
export function getAdminEmailSetting() {

View File

@@ -3275,6 +3275,80 @@
line-height: 1.5;
}
/* --- 服务协议条款编辑区 -------------------------------------------------------
模板入口与字数提示紧贴正文编辑框:它们是「填写条款」这一步的辅助操作,
放回表单内部后与保存行(页脚)在视觉上分离,避免被读成两个并列的主操作。
内边距:同页其他区块(注册策略、Linux.do)的表单都包在带边框和内边距的
子卡片里,本区块的 Form.Item 是裸的,不加 padding 会左右贴死卡片边缘,
与区块头(20px)和页脚(14px 20px)对不齐。窄屏 16px,≥1024px 与
summary 的 20px 对齐。 */
.admin-access-agreement-section > .admin-settings-section-content > .ant-form {
padding: 14px 16px 8px;
}
@media (min-width: 1024px) {
.admin-access-agreement-section > .admin-settings-section-content > .ant-form {
padding: 18px 20px 10px;
}
}
/* 协议名称是短文本,占满 1400px 宽的卡片视觉重心失衡;限宽到表单常用档位。 */
.admin-access-agreement-title-field {
max-width: 480px;
}
.admin-access-agreement-content-field.ant-form-item {
margin-bottom: 10px;
}
.admin-access-agreement-toolbar {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 14px;
margin-bottom: 4px;
border: 1px solid var(--admin-card-border);
border-radius: 8px;
background: color-mix(in srgb, var(--admin-layer-2) 48%, var(--admin-layer-1));
padding: 8px 10px;
}
.admin-access-agreement-template-note {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
color: var(--admin-text-muted);
font-size: 11px;
line-height: 1.5;
}
.admin-access-agreement-template-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.admin-access-agreement-metric {
color: var(--admin-text-muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
line-height: 1.5;
white-space: nowrap;
}
/* 内容为空时字数提示没有信息量,窄屏优先让位给操作按钮。 */
@media (max-width: 640px) {
.admin-access-agreement-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
.admin-access-load-error,
.admin-access-loading-card {
min-height: 220px;
@@ -6230,6 +6304,7 @@
.admin-email-delivery-section,
.admin-access-registration-section,
.admin-access-linuxdo-section,
.admin-access-agreement-section,
.admin-intercept-policy-section,
.admin-third-party-credential-section {
border-color: color-mix(in srgb, var(--foreground) 22%, var(--admin-card-border)) !important;
@@ -6239,17 +6314,18 @@
.admin-email-delivery-section > .admin-settings-section-summary,
.admin-access-registration-section > .admin-settings-section-summary,
.admin-access-linuxdo-section > .admin-settings-section-summary,
.admin-access-agreement-section > .admin-settings-section-summary,
.admin-intercept-policy-section > .admin-settings-section-summary,
.admin-third-party-credential-section > .admin-settings-section-summary {
background: color-mix(in srgb, var(--foreground) 3%, var(--admin-layer-1));
}
:is(.admin-storage-mode-section, .admin-email-delivery-section, .admin-access-registration-section, .admin-access-linuxdo-section, .admin-intercept-policy-section, .admin-third-party-credential-section) > .admin-settings-section-summary h2 {
:is(.admin-storage-mode-section, .admin-email-delivery-section, .admin-access-registration-section, .admin-access-linuxdo-section, .admin-access-agreement-section, .admin-intercept-policy-section, .admin-third-party-credential-section) > .admin-settings-section-summary h2 {
font-size: 16px;
letter-spacing: -0.01em;
}
:is(.admin-storage-mode-section, .admin-email-delivery-section, .admin-access-registration-section, .admin-access-linuxdo-section, .admin-intercept-policy-section, .admin-third-party-credential-section) > .admin-settings-section-summary p {
:is(.admin-storage-mode-section, .admin-email-delivery-section, .admin-access-registration-section, .admin-access-linuxdo-section, .admin-access-agreement-section, .admin-intercept-policy-section, .admin-third-party-credential-section) > .admin-settings-section-summary p {
max-width: 760px;
margin-top: 5px;
font-size: 12px;

View File

@@ -301,6 +301,10 @@
--auth-card-bg: #ffffff;
--auth-accent: #2563eb;
--auth-muted: #737373;
/* 协议勾选框的填充色与对勾色。必须成对维护:两者相近会在某一主题下
退化成看不见的勾选态。深色主题下这里与 --foreground 的白色相反。 */
--auth-check-bg: #171717;
--auth-check-fg: #ffffff;
/* 可配置皮肤的组件入口。经典主题保持这些项目原始值;自定义主题在
React 启动前由公开外观配置覆盖,页面和组件只消费这些语义变量。 */
@@ -474,6 +478,162 @@
color: var(--foreground);
}
/* --- 注册页协议同意行 ---------------------------------------------------------
勾选热区必须覆盖“文字 + 协议名”整段,但协议名同时是可点击的阅读入口,
因此热区由 label 承担,协议名按钮用 stopPropagation 之外的方式独立可点:
这里只做视觉与排布,行为仍由 React 侧控制。 */
.auth-agreement-row {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: var(--control-border-width) solid transparent;
border-radius: var(--r-sm);
color: var(--auth-muted);
font-size: var(--fs-label);
line-height: 1.65;
transition:
border-color var(--motion-instant) ease-out,
background-color var(--motion-instant) ease-out,
color var(--motion-instant) ease-out;
}
.auth-agreement-row:hover {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
/* 已勾选时用极轻的强调底色标记“这一步已完成”,不做成第二主色。 */
.auth-agreement-row[data-accepted="true"] {
border-color: color-mix(in srgb, var(--foreground) 10%, transparent);
background: color-mix(in srgb, var(--foreground) 4.5%, transparent);
color: color-mix(in srgb, var(--foreground) 78%, transparent);
}
/* 未勾选时给协议名一个克制的提示底色,指向“这里还没有同意”。 */
.auth-agreement-row[data-accepted="false"] .auth-agreement-link {
text-decoration-line: underline;
text-decoration-color: color-mix(in srgb, var(--auth-accent) 42%, transparent);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.auth-agreement-row .ant-checkbox-wrapper {
flex: 0 0 auto;
margin-inline-end: 0;
color: inherit;
font-size: inherit;
line-height: inherit;
}
/* AntD 默认把 checkbox 行高 22px 与文案首行错开 1px 左右,这里统一到首行。 */
.auth-agreement-row .ant-checkbox {
align-self: flex-start;
top: 0.1em;
}
.auth-agreement-row .ant-checkbox + span {
padding-inline-start: var(--space-2);
padding-inline-end: 0;
}
.auth-agreement-label {
color: inherit;
}
.auth-agreement-link {
display: inline;
margin: 0;
padding: 0;
border: 0;
background: none;
color: var(--auth-accent);
font: inherit;
line-height: inherit;
text-align: start;
transition: color var(--motion-instant) ease-out;
}
.auth-agreement-link:hover {
color: var(--foreground);
}
.auth-agreement-link:focus-visible {
border-radius: 3px;
outline: var(--focus-ring-width) solid var(--ring);
outline-offset: 3px;
}
/* --- 协议阅读弹窗 -------------------------------------------------------------
长条款需要稳定的阅读列宽和可预期的滚动边界;正文段落之间用间距而不是
空行撑开,避免后台多段文本被渲染成松散的空洞。 */
.auth-agreement-modal .ant-modal-container {
border-radius: var(--r-lg);
}
.auth-agreement-modal .ant-modal-header {
margin-bottom: var(--space-1);
}
.auth-agreement-modal .ant-modal-title {
font-size: var(--fs-title-sm, 16px);
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.5;
}
.auth-agreement-body {
max-height: min(56vh, 460px);
overflow-y: auto;
padding-inline-end: var(--space-3);
overscroll-behavior: contain;
color: color-mix(in srgb, var(--foreground) 74%, transparent);
font-size: var(--fs-body, 13px);
line-height: 1.85;
}
.auth-agreement-body p {
margin: 0;
}
.auth-agreement-body p + p {
margin-top: var(--space-4);
}
/* 启用“一、二、三”式条目标题时,标题段单独加重,正文段保持常规字重。 */
.auth-agreement-body p[data-role="heading"] {
margin-top: var(--space-5);
color: var(--foreground);
font-weight: 600;
}
.auth-agreement-body p[data-role="heading"]:first-child {
margin-top: 0;
}
.auth-agreement-empty {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-3);
border: var(--control-border-width) dashed color-mix(in srgb, var(--foreground) 16%, transparent);
border-radius: var(--r-md);
color: var(--auth-muted);
font-size: var(--fs-label);
justify-content: center;
}
.auth-agreement-meta {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: var(--control-border-width) solid var(--border);
color: var(--auth-muted);
font-size: var(--fs-micro, 11px);
letter-spacing: 0.01em;
}
.site-compliance-footer {
display: flex;
min-width: 0;
@@ -799,6 +959,10 @@
--auth-card-bg: #121318;
--auth-accent: #93c5fd;
--auth-muted: #8a8b91;
/* 深色主题下勾选框用浅底 + 深勾,与浅色主题的深底 + 白勾形成互补;
直接复用 --foreground 会得到白底白勾。 */
--auth-check-bg: #e5e5e5;
--auth-check-fg: #171717;
--foreground: #ffffff;
--border: #222222;
--ring: oklch(0.556 0 0);
@@ -6997,6 +7161,125 @@ html:not(.dark) .creation-studio-control[aria-expanded="true"] { background: #e7
caret-color: var(--foreground);
}
/* 验证码输入行:输入框与获取按钮同排,按钮贴右侧并用竖线分隔。
倒计时/禁用时按钮降到弱化态,避免和主提交按钮争夺注意力。 */
.auth-code-field {
display: flex;
align-items: stretch;
gap: 0;
border: var(--control-border-width) solid transparent;
border-radius: var(--radius, var(--r-sm));
background: color-mix(in srgb, var(--foreground) 5.5%, transparent);
transition:
border-color var(--motion-instant) ease-out,
background-color var(--motion-instant) ease-out,
box-shadow var(--motion-instant) ease-out;
}
.auth-code-field:hover {
border-color: var(--border);
background: color-mix(in srgb, var(--foreground) 7%, transparent);
}
/* 焦点环挂在整行容器上,内部输入框不再叠加第二层高亮。 */
.auth-code-field:focus-within {
border-color: var(--ring);
background: color-mix(in srgb, var(--foreground) 8.5%, transparent);
box-shadow: 0 0 0 var(--focus-ring-width) color-mix(in srgb, var(--ring) 18%, transparent);
}
.auth-code-field .ant-input,
.auth-code-field .ant-input-affix-wrapper {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.auth-code-field .ant-input:focus,
.auth-code-field .ant-input-affix-wrapper-focused {
border-color: transparent !important;
box-shadow: none !important;
}
.auth-code-send {
flex: 0 0 auto;
align-self: center;
height: auto;
min-height: 26px;
margin-inline-end: 5px;
padding-inline: var(--space-3);
border: 0;
border-inline-start: var(--control-border-width) solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-radius: 0;
background: transparent;
box-shadow: none;
color: var(--auth-accent);
font-size: var(--fs-label);
font-weight: 500;
white-space: nowrap;
}
.auth-code-send:hover:not(:disabled) {
background: transparent;
color: var(--foreground);
}
/* 倒计时/发送中:保持排版稳定,只做颜色降级,不改变宽度导致输入框跳动。 */
.auth-code-send:disabled,
.auth-code-send[data-counting="true"] {
border-inline-start-color: color-mix(in srgb, var(--foreground) 10%, transparent);
background: transparent !important;
color: var(--auth-muted);
font-variant-numeric: tabular-nums;
}
.auth-code-field.is-busy .ant-input {
color: var(--auth-muted);
}
/* 协议勾选框:圆角方框 + 主题主色填充。
注意 AntD 6 把方框样式渲染在 .ant-checkbox 本身(对齐 antd/es/checkbox/style),
.ant-checkbox-inner 在内联样式阶段已被移除,因此所有规则必须打在外层元素上,
否则样式不会生效。
另外方框尺寸来自 checkboxSize = controlHeight / 2(本主题下为 18px),
由 cssinjs 生成,选择器权重不低于这里的规则,所以尺寸必须用 !important 收敛到 16px,
否则视觉上会是一个偏大的方块。对勾是 .ant-checkbox::after 的边框,
默认取 colorWhite,深色主题下 colorPrimary 也是近白,会退化成白底白勾,需单独覆盖。 */
.auth-agreement-row .ant-checkbox {
align-self: center;
width: 16px !important;
height: 16px !important;
border-color: color-mix(in srgb, var(--foreground) 32%, transparent) !important;
border-radius: var(--checkbox-radius, 4px);
background: color-mix(in srgb, var(--foreground) 3%, transparent) !important;
transition:
border-color var(--motion-instant) ease-out,
background-color var(--motion-instant) ease-out;
}
.auth-agreement-row .ant-checkbox::after {
border-color: var(--auth-check-fg) !important;
border-width: 2px !important;
}
.auth-agreement-row .ant-checkbox-wrapper:hover .ant-checkbox:not(.ant-checkbox-checked),
.auth-agreement-row .ant-checkbox:hover:not(.ant-checkbox-checked) {
border-color: color-mix(in srgb, var(--foreground) 55%, transparent) !important;
background: color-mix(in srgb, var(--foreground) 6%, transparent) !important;
}
.auth-agreement-row .ant-checkbox-checked,
.auth-agreement-row .ant-checkbox-wrapper:hover .ant-checkbox-checked,
.auth-agreement-row .ant-checkbox-checked:hover {
border-color: var(--auth-check-bg) !important;
background: var(--auth-check-bg) !important;
}
.auth-agreement-row .ant-checkbox:has(.ant-checkbox-input:focus-visible) {
outline: none;
box-shadow: 0 0 0 var(--focus-ring-width) color-mix(in srgb, var(--ring) 20%, transparent);
}
:where(.ant-modal, .ant-drawer, .ant-select-dropdown, .ant-dropdown, .ant-popover) {
overscroll-behavior: contain;
}

View File

@@ -25,17 +25,45 @@ describe("注册服务协议", () => {
expect(page).toContain("acceptedTerms: agreementAccepted");
expect(page).toContain("agreementAccepted ? linuxDOLoginURL(next, true) : undefined");
expect(page).toContain("请先同意影策服务协议");
expect(page).toContain("message.warning(`请先同意${agreementTitle}`)");
expect(login).toContain("linuxDOLoginURL(next)");
expect(api).toContain("acceptedTerms: boolean");
expect(api).toContain("acceptedTerms?: boolean");
});
test("协议使用影策标题并提供正文承载区域", () => {
test("协议标题与条款来自后台配置而不是硬编码常量", () => {
const page = readFileSync(resolve(import.meta.dir, "../src/pages/auth/register.tsx"), "utf8");
expect(page).toContain('title="影策服务协议"');
expect(page).toContain("SERVICE_AGREEMENT.map");
expect(page).toContain("服务协议内容待补充。");
expect(page).not.toContain("SERVICE_AGREEMENT");
expect(page).toContain("settings?.agreementTitle");
expect(page).toContain("settings?.agreementContent");
expect(page).toContain("title={agreementTitle}");
expect(page).toContain("agreementParagraphs.map");
// 条款留空时的占位提示;断言关键词而非整句,允许后续补充指引文案。
expect(page).toContain("服务协议内容待补充");
});
test("读取设置失败时不猜协议标题,改为提示并允许重新读取", () => {
const page = readFileSync(resolve(import.meta.dir, "../src/pages/auth/register.tsx"), "utf8");
// 失败必须留下状态位,不能静默降级成品牌名拼接的协议名。
expect(page).toContain("[settingsFailed, setSettingsFailed] = useState(false)");
expect(page).toContain("setSettingsFailed(true)");
expect(page).toContain("读取注册设置失败,暂时无法确认服务协议名称与条款。");
expect(page).toContain("setSettingsReloadKey((value) => value + 1)");
// 协议入口只在拿到设置后渲染,读不到配置就不展示协议名。
expect(page).toContain("{settings ? (");
});
test("公开认证设置暴露协议字段,管理接口可回传协议正文", () => {
const auth = readFileSync(resolve(import.meta.dir, "../src/services/api/auth.ts"), "utf8");
const wallet = readFileSync(resolve(import.meta.dir, "../src/services/api/wallet.ts"), "utf8");
const panel = readFileSync(resolve(import.meta.dir, "../src/pages/admin/components/access-settings-panel.tsx"), "utf8");
expect(auth).toContain("agreementTitle?: string; agreementContent?: string");
expect(wallet).toContain("agreementTitle?: string");
expect(wallet).toContain("agreementContent?: string");
expect(panel).toContain("服务协议名称与条款");
expect(panel).toContain("saveAgreement");
});
});

View File

@@ -7,7 +7,9 @@ const source = (path: string) => readFileSync(new URL(`../src/${path}`, import.m
describe("authentication follows the application theme", () => {
test("classic login surfaces have distinct light and dark palettes", () => {
const css = source("styles/globals.css");
// globals.css 在不同平台上可能是 LF 或 CRLF;先归一化再分段,
// 否则按字面换行查找 .dark 段会返回 -1,把断言变成永远失败。
const css = source("styles/globals.css").replace(/\r\n/g, "\n");
const dark = css.slice(css.indexOf(".dark {\n --background:"));
for (const [mode, background, panel, card] of [
["light", "#ffffff", "#f7f7f7", "#ffffff"],