mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-30 17:58:00 +08:00
Merge branch 'feat/2.6.0-beta4' into feat/2.6.0
# Conflicts: # src/backend/bisheng/knowledge/domain/models/knowledge.py
This commit is contained in:
+42
-68
@@ -1,17 +1,17 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple, Any
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import case, func, or_
|
||||
from sqlmodel import select, col, update
|
||||
from sqlmodel import col, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from bisheng.channel.domain.models.channel import Channel, ChannelVisibilityEnum
|
||||
from bisheng.channel.domain.repositories.interfaces.channel_repository import ChannelRepository
|
||||
from bisheng.common.models.space_channel_member import (
|
||||
SpaceChannelMember,
|
||||
REJECTED_STATUS_DISPLAY_WINDOW,
|
||||
BusinessTypeEnum,
|
||||
MembershipStatusEnum,
|
||||
REJECTED_STATUS_DISPLAY_WINDOW,
|
||||
SpaceChannelMember,
|
||||
)
|
||||
from bisheng.common.repositories.implementations.base_repository_impl import BaseRepositoryImpl
|
||||
|
||||
@@ -22,7 +22,7 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(session, Channel)
|
||||
|
||||
async def find_channels_by_ids(self, channel_ids: List[str]) -> List[Channel]:
|
||||
async def find_channels_by_ids(self, channel_ids: list[str]) -> list[Channel]:
|
||||
"""Find channels by a list of channel IDs."""
|
||||
if not channel_ids:
|
||||
return []
|
||||
@@ -30,8 +30,9 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
result = await self.session.exec(query)
|
||||
return list(result.all())
|
||||
|
||||
async def find_square_channels(self, user_id: int, keyword: Optional[str] = None,
|
||||
page: int = 1, page_size: int = 20) -> List[Tuple[Any, ...]]:
|
||||
async def find_square_channels(
|
||||
self, user_id: int, keyword: str | None = None, page: int = 1, page_size: int = 20
|
||||
) -> list[tuple[Any, ...]]:
|
||||
"""
|
||||
Find released channels for the channel square with subscription status and subscriber count.
|
||||
Uses multi-table LEFT JOIN:
|
||||
@@ -42,15 +43,15 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
"""
|
||||
rejection_cutoff = datetime.now() - REJECTED_STATUS_DISPLAY_WINDOW
|
||||
|
||||
# Subquery: count subscribers (status=ACTIVE) per channel
|
||||
# Subquery: count unique active subscribers per channel
|
||||
subscriber_subq = (
|
||||
select(
|
||||
SpaceChannelMember.business_id,
|
||||
func.count().label('subscriber_count')
|
||||
func.count(func.distinct(SpaceChannelMember.user_id)).label("subscriber_count"),
|
||||
)
|
||||
.where(
|
||||
SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL,
|
||||
SpaceChannelMember.status == MembershipStatusEnum.ACTIVE
|
||||
SpaceChannelMember.status == MembershipStatusEnum.ACTIVE,
|
||||
)
|
||||
.group_by(SpaceChannelMember.business_id)
|
||||
.subquery()
|
||||
@@ -60,36 +61,26 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
query = (
|
||||
select(
|
||||
Channel,
|
||||
SpaceChannelMember.status.label('user_subscription_status'),
|
||||
SpaceChannelMember.update_time.label('user_subscription_update_time'),
|
||||
func.coalesce(subscriber_subq.c.subscriber_count, 0).label('subscriber_count')
|
||||
SpaceChannelMember.status.label("user_subscription_status"),
|
||||
SpaceChannelMember.update_time.label("user_subscription_update_time"),
|
||||
func.coalesce(subscriber_subq.c.subscriber_count, 0).label("subscriber_count"),
|
||||
)
|
||||
.outerjoin(
|
||||
SpaceChannelMember,
|
||||
(SpaceChannelMember.business_id == Channel.id) &
|
||||
(SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL) &
|
||||
(SpaceChannelMember.user_id == user_id)
|
||||
)
|
||||
.outerjoin(
|
||||
subscriber_subq,
|
||||
subscriber_subq.c.business_id == Channel.id
|
||||
)
|
||||
.where(
|
||||
Channel.is_released == True,
|
||||
Channel.visibility != ChannelVisibilityEnum.PRIVATE
|
||||
(SpaceChannelMember.business_id == Channel.id)
|
||||
& (SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL)
|
||||
& (SpaceChannelMember.user_id == user_id),
|
||||
)
|
||||
.outerjoin(subscriber_subq, subscriber_subq.c.business_id == Channel.id)
|
||||
.where(Channel.is_released == True, Channel.visibility != ChannelVisibilityEnum.PRIVATE)
|
||||
)
|
||||
|
||||
# Apply keyword filter (fuzzy search on name and description)
|
||||
if keyword:
|
||||
like_pattern = f'%{keyword}%'
|
||||
query = query.where(
|
||||
or_(
|
||||
Channel.name.like(like_pattern),
|
||||
Channel.description.like(like_pattern)
|
||||
)
|
||||
)
|
||||
like_pattern = f"%{keyword}%"
|
||||
query = query.where(or_(Channel.name.like(like_pattern), Channel.description.like(like_pattern)))
|
||||
|
||||
subscriber_count = func.coalesce(subscriber_subq.c.subscriber_count, 0)
|
||||
subscription_order = case(
|
||||
(SpaceChannelMember.status.is_(None), 0),
|
||||
(
|
||||
@@ -97,11 +88,13 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
& (SpaceChannelMember.update_time < rejection_cutoff),
|
||||
0,
|
||||
),
|
||||
else_=1
|
||||
else_=1,
|
||||
)
|
||||
query = query.order_by(
|
||||
subscription_order.asc(),
|
||||
func.coalesce(Channel.update_time, Channel.create_time).desc()
|
||||
subscriber_count.desc(),
|
||||
func.coalesce(Channel.update_time, Channel.create_time).desc(),
|
||||
Channel.id.asc(),
|
||||
)
|
||||
|
||||
# Pagination
|
||||
@@ -111,9 +104,7 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
result = await self.session.exec(query)
|
||||
return list(result.all())
|
||||
|
||||
async def find_public_recommend_channels(
|
||||
self, user_id: int, candidate_limit: int = 100
|
||||
) -> List[Tuple[Any, ...]]:
|
||||
async def find_public_recommend_channels(self, user_id: int, candidate_limit: int = 100) -> list[tuple[Any, ...]]:
|
||||
"""
|
||||
Find released PUBLIC channels for the home-page discovery carousel.
|
||||
|
||||
@@ -125,13 +116,10 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
pre-filter — newest channels first, bounded to keep the ES batch small.
|
||||
"""
|
||||
subscriber_subq = (
|
||||
select(
|
||||
SpaceChannelMember.business_id,
|
||||
func.count().label('subscriber_count')
|
||||
)
|
||||
select(SpaceChannelMember.business_id, func.count().label("subscriber_count"))
|
||||
.where(
|
||||
SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL,
|
||||
SpaceChannelMember.status == MembershipStatusEnum.ACTIVE
|
||||
SpaceChannelMember.status == MembershipStatusEnum.ACTIVE,
|
||||
)
|
||||
.group_by(SpaceChannelMember.business_id)
|
||||
.subquery()
|
||||
@@ -140,24 +128,18 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
query = (
|
||||
select(
|
||||
Channel,
|
||||
SpaceChannelMember.status.label('user_subscription_status'),
|
||||
SpaceChannelMember.update_time.label('user_subscription_update_time'),
|
||||
func.coalesce(subscriber_subq.c.subscriber_count, 0).label('subscriber_count')
|
||||
SpaceChannelMember.status.label("user_subscription_status"),
|
||||
SpaceChannelMember.update_time.label("user_subscription_update_time"),
|
||||
func.coalesce(subscriber_subq.c.subscriber_count, 0).label("subscriber_count"),
|
||||
)
|
||||
.outerjoin(
|
||||
SpaceChannelMember,
|
||||
(SpaceChannelMember.business_id == Channel.id) &
|
||||
(SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL) &
|
||||
(SpaceChannelMember.user_id == user_id)
|
||||
)
|
||||
.outerjoin(
|
||||
subscriber_subq,
|
||||
subscriber_subq.c.business_id == Channel.id
|
||||
)
|
||||
.where(
|
||||
Channel.is_released == True,
|
||||
Channel.visibility == ChannelVisibilityEnum.PUBLIC
|
||||
(SpaceChannelMember.business_id == Channel.id)
|
||||
& (SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL)
|
||||
& (SpaceChannelMember.user_id == user_id),
|
||||
)
|
||||
.outerjoin(subscriber_subq, subscriber_subq.c.business_id == Channel.id)
|
||||
.where(Channel.is_released == True, Channel.visibility == ChannelVisibilityEnum.PUBLIC)
|
||||
.order_by(func.coalesce(Channel.update_time, Channel.create_time).desc())
|
||||
.limit(candidate_limit)
|
||||
)
|
||||
@@ -165,25 +147,17 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
result = await self.session.exec(query)
|
||||
return list(result.all())
|
||||
|
||||
async def count_square_channels(self, keyword: Optional[str] = None) -> int:
|
||||
async def count_square_channels(self, keyword: str | None = None) -> int:
|
||||
"""Count total released channels matching the keyword filter."""
|
||||
query = (
|
||||
select(func.count())
|
||||
.select_from(Channel)
|
||||
.where(
|
||||
Channel.is_released == True,
|
||||
Channel.visibility != ChannelVisibilityEnum.PRIVATE
|
||||
)
|
||||
.where(Channel.is_released == True, Channel.visibility != ChannelVisibilityEnum.PRIVATE)
|
||||
)
|
||||
|
||||
if keyword:
|
||||
like_pattern = f'%{keyword}%'
|
||||
query = query.where(
|
||||
or_(
|
||||
Channel.name.like(like_pattern),
|
||||
Channel.description.like(like_pattern)
|
||||
)
|
||||
)
|
||||
like_pattern = f"%{keyword}%"
|
||||
query = query.where(or_(Channel.name.like(like_pattern), Channel.description.like(like_pattern)))
|
||||
|
||||
result = await self.session.exec(query)
|
||||
return result.one()
|
||||
@@ -198,7 +172,7 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
|
||||
referenced.update(source_list)
|
||||
return referenced
|
||||
|
||||
def update_channel_latest_article_update_time(self, channles: List[Channel]) -> List[Channel]:
|
||||
def update_channel_latest_article_update_time(self, channles: list[Channel]) -> list[Channel]:
|
||||
for channel in channles:
|
||||
stmt = (
|
||||
update(Channel)
|
||||
|
||||
@@ -1240,7 +1240,7 @@ class ChannelService:
|
||||
- Supports fuzzy search by channel name and description
|
||||
- Unsubscribed/unapplied channels are shown first
|
||||
- Subscribed/applied channels are shown last
|
||||
- Within each group, sorted by update_time descending
|
||||
- Within each group, sorted by unique active subscriber count descending
|
||||
"""
|
||||
# 1. Multi-table join query for channels with subscription info
|
||||
rows = await self.channel_repository.find_square_channels(
|
||||
|
||||
@@ -841,11 +841,11 @@ class KnowledgeDao(KnowledgeBase):
|
||||
|
||||
kid_str = col(Knowledge.id).cast(String)
|
||||
|
||||
# Subquery: count subscribers (status=ACTIVE) per space
|
||||
# Subquery: count unique active subscribers per space
|
||||
subscriber_subq = (
|
||||
select(
|
||||
SpaceChannelMember.business_id,
|
||||
func.count().label("subscriber_count"),
|
||||
func.count(func.distinct(SpaceChannelMember.user_id)).label("subscriber_count"),
|
||||
)
|
||||
.where(
|
||||
SpaceChannelMember.business_type == BusinessTypeEnum.SPACE,
|
||||
@@ -890,7 +890,10 @@ class KnowledgeDao(KnowledgeBase):
|
||||
)
|
||||
)
|
||||
|
||||
# Sort: not-subscribed first, then by update_time DESC
|
||||
subscriber_count = func.coalesce(subscriber_subq.c.subscriber_count, 0)
|
||||
|
||||
# Sort by lightweight membership facts only. Permission evaluation remains
|
||||
# a response-layer concern and does not affect SQL pagination.
|
||||
subscription_order = case(
|
||||
(SpaceChannelMember.status.is_(None), 0),
|
||||
(
|
||||
@@ -902,7 +905,9 @@ class KnowledgeDao(KnowledgeBase):
|
||||
)
|
||||
query = query.order_by(
|
||||
subscription_order.asc(),
|
||||
subscriber_count.desc(),
|
||||
func.coalesce(Knowledge.update_time, Knowledge.create_time).desc(),
|
||||
Knowledge.id.asc(),
|
||||
)
|
||||
|
||||
# Pagination
|
||||
|
||||
+22
-1
@@ -428,7 +428,28 @@ class DepartmentKnowledgeSpaceService:
|
||||
(await DepartmentKnowledgeSpaceDao.aget_department_ids_by_space_ids(member_space_ids)).keys()
|
||||
)
|
||||
|
||||
space_ids = department_space_ids | member_bound_space_ids
|
||||
# A user can also gain access to a department space through an ad-hoc
|
||||
# authorization (viewer/editor/manager granted on the permission panel).
|
||||
# That path writes only an FGA relation — no membership row, and the
|
||||
# user need not be a member of the bound department — so neither branch
|
||||
# above catches it and the space would only surface under "我加入的".
|
||||
# Intersect the user's FGA-readable spaces with the department-bound set
|
||||
# so an individually-authorized user sees it under 部门知识空间 instead.
|
||||
accessible_ids = await PermissionService.list_accessible_ids(
|
||||
user_id=login_user.user_id,
|
||||
relation="can_read",
|
||||
object_type="knowledge_space",
|
||||
login_user=login_user,
|
||||
)
|
||||
authorized_bound_space_ids: set[int] = set()
|
||||
if accessible_ids:
|
||||
accessible_int_ids = [int(sid) for sid in accessible_ids if str(sid).isdigit()]
|
||||
if accessible_int_ids:
|
||||
authorized_bound_space_ids = set(
|
||||
(await DepartmentKnowledgeSpaceDao.aget_department_ids_by_space_ids(accessible_int_ids)).keys()
|
||||
)
|
||||
|
||||
space_ids = department_space_ids | member_bound_space_ids | authorized_bound_space_ids
|
||||
|
||||
if not space_ids:
|
||||
return []
|
||||
|
||||
@@ -2106,7 +2106,7 @@ class KnowledgeSpaceService(KnowledgeUtils):
|
||||
Return PUBLIC/APPROVAL spaces for the Knowledge Square with pagination, sorted by:
|
||||
1. Not-joined first (easier to explore)
|
||||
2. Already-joined or pending last
|
||||
3. Within each group: sorted by update_time DESC
|
||||
3. Within each group: unique active subscriber count descending
|
||||
Sorting and pagination are handled at the SQL level for efficiency.
|
||||
Returns: {"total": int, "page": int, "page_size": int, "data": List[KnowledgeSpaceInfoResp]}
|
||||
"""
|
||||
|
||||
@@ -528,6 +528,10 @@ async def list_user(
|
||||
group_dict = {}
|
||||
for one, avatar_url in zip(users, avatar_urls):
|
||||
one_data = one.model_dump()
|
||||
# Never expose sensitive/internal User columns in the list response — the
|
||||
# ORM dump carries the full row; the frontend uses none of these fields.
|
||||
for sensitive_field in ("password", "password_update_time", "token_version"):
|
||||
one_data.pop(sensitive_field, None)
|
||||
primary_dept_id = primary_dept_by_user.get(int(one.user_id)) if one.user_id is not None else None
|
||||
one_data["department_id"] = primary_dept_id
|
||||
if with_department_path:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from bisheng.channel.domain.models.channel import Channel, ChannelVisibilityEnum
|
||||
from bisheng.channel.domain.repositories.implementations.channel_repository_impl import (
|
||||
ChannelRepositoryImpl,
|
||||
)
|
||||
from bisheng.common.models.space_channel_member import (
|
||||
BusinessTypeEnum,
|
||||
MembershipStatusEnum,
|
||||
SpaceChannelMember,
|
||||
UserRoleEnum,
|
||||
)
|
||||
from bisheng.core.context.tenant import bypass_tenant_filter
|
||||
|
||||
|
||||
def _channel(channel_id: str, update_time: datetime) -> Channel:
|
||||
return Channel(
|
||||
id=channel_id,
|
||||
name=channel_id,
|
||||
description="",
|
||||
source_list=[],
|
||||
visibility=ChannelVisibilityEnum.PUBLIC,
|
||||
filter_rules=[],
|
||||
user_id=99,
|
||||
is_released=True,
|
||||
create_time=update_time,
|
||||
update_time=update_time,
|
||||
)
|
||||
|
||||
|
||||
def _member(
|
||||
channel_id: str,
|
||||
user_id: int,
|
||||
status: MembershipStatusEnum = MembershipStatusEnum.ACTIVE,
|
||||
) -> SpaceChannelMember:
|
||||
return SpaceChannelMember(
|
||||
business_id=channel_id,
|
||||
business_type=BusinessTypeEnum.CHANNEL,
|
||||
user_id=user_id,
|
||||
user_role=UserRoleEnum.MEMBER,
|
||||
status=status,
|
||||
update_time=datetime(2026, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_square_orders_unsubscribed_before_applied_then_by_unique_subscriber_count():
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Channel.__table__.create)
|
||||
await connection.run_sync(SpaceChannelMember.__table__.create)
|
||||
|
||||
now = datetime(2026, 1, 1)
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
session.add_all(
|
||||
[
|
||||
_channel("00-not-member-popular", now),
|
||||
_channel("01-pending-popular", now),
|
||||
_channel("02-not-member-less-popular", now),
|
||||
_channel("03-member-popular", now),
|
||||
_channel("04-member-less-popular", now),
|
||||
_member("00-not-member-popular", 14),
|
||||
_member("00-not-member-popular", 14),
|
||||
_member("00-not-member-popular", 15),
|
||||
_member("01-pending-popular", 7, MembershipStatusEnum.PENDING),
|
||||
_member("01-pending-popular", 10),
|
||||
_member("01-pending-popular", 10),
|
||||
_member("01-pending-popular", 11),
|
||||
_member("02-not-member-less-popular", 12),
|
||||
_member("03-member-popular", 7),
|
||||
_member("03-member-popular", 13),
|
||||
_member("03-member-popular", 13),
|
||||
_member("04-member-less-popular", 7),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
with bypass_tenant_filter():
|
||||
rows = await ChannelRepositoryImpl(session).find_square_channels(
|
||||
user_id=7,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
assert [row[0].id for row in rows] == [
|
||||
"00-not-member-popular",
|
||||
"02-not-member-less-popular",
|
||||
"01-pending-popular",
|
||||
"03-member-popular",
|
||||
"04-member-less-popular",
|
||||
]
|
||||
assert [row[3] for row in rows] == [2, 1, 2, 2, 1]
|
||||
assert rows[2][1] == MembershipStatusEnum.PENDING
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from bisheng.common.models.space_channel_member import (
|
||||
BusinessTypeEnum,
|
||||
MembershipStatusEnum,
|
||||
SpaceChannelMember,
|
||||
UserRoleEnum,
|
||||
)
|
||||
from bisheng.core.context.tenant import bypass_tenant_filter
|
||||
from bisheng.knowledge.domain.models import knowledge as knowledge_module
|
||||
from bisheng.knowledge.domain.models.knowledge import (
|
||||
AuthTypeEnum,
|
||||
Knowledge,
|
||||
KnowledgeDao,
|
||||
KnowledgeTypeEnum,
|
||||
)
|
||||
|
||||
|
||||
def _space(space_id: int, update_time: datetime) -> Knowledge:
|
||||
return Knowledge(
|
||||
id=space_id,
|
||||
user_id=99,
|
||||
name=f"space-{space_id}",
|
||||
type=KnowledgeTypeEnum.SPACE.value,
|
||||
description="",
|
||||
is_released=True,
|
||||
auth_type=AuthTypeEnum.PUBLIC,
|
||||
create_time=update_time,
|
||||
update_time=update_time,
|
||||
)
|
||||
|
||||
|
||||
def _member(
|
||||
space_id: int,
|
||||
user_id: int,
|
||||
status: MembershipStatusEnum = MembershipStatusEnum.ACTIVE,
|
||||
) -> SpaceChannelMember:
|
||||
return SpaceChannelMember(
|
||||
business_id=str(space_id),
|
||||
business_type=BusinessTypeEnum.SPACE,
|
||||
user_id=user_id,
|
||||
user_role=UserRoleEnum.MEMBER,
|
||||
status=status,
|
||||
update_time=datetime(2026, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_square_orders_unsubscribed_before_applied_then_by_unique_subscriber_count(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Knowledge.__table__.create)
|
||||
await connection.run_sync(SpaceChannelMember.__table__.create)
|
||||
|
||||
now = datetime(2026, 1, 1)
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
session.add_all(
|
||||
[
|
||||
_space(1, now),
|
||||
_space(2, now),
|
||||
_space(3, now),
|
||||
_space(4, now),
|
||||
_space(5, now),
|
||||
_member(1, 7, MembershipStatusEnum.PENDING),
|
||||
_member(1, 10),
|
||||
_member(1, 10),
|
||||
_member(1, 11),
|
||||
_member(2, 12),
|
||||
_member(3, 7),
|
||||
_member(3, 13),
|
||||
_member(3, 13),
|
||||
_member(4, 7),
|
||||
_member(5, 14),
|
||||
_member(5, 14),
|
||||
_member(5, 15),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _get_test_session():
|
||||
yield session
|
||||
|
||||
monkeypatch.setattr(knowledge_module, "get_async_db_session", _get_test_session)
|
||||
with bypass_tenant_filter():
|
||||
rows = await KnowledgeDao.async_get_public_spaces_paginated(
|
||||
user_id=7,
|
||||
page=1,
|
||||
page_size=20,
|
||||
)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
assert [row[0].id for row in rows] == [5, 2, 1, 3, 4]
|
||||
assert [row[3] for row in rows] == [2, 1, 2, 2, 1]
|
||||
assert rows[2][1] == MembershipStatusEnum.PENDING
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* rspack pre-loader for the docs site: strip internal working sections from
|
||||
* the spec markdown (docs-ui-refactor/*.md) BEFORE the MDX compiler sees the
|
||||
* source.
|
||||
*
|
||||
* Why a loader and not a remark plugin: rspress extracts the page TOC (and
|
||||
* search index) before user remarkPlugins run, so a remark-level strip left
|
||||
* ghost entries in the right-hand outline. Text-level stripping up front
|
||||
* keeps body / TOC / search consistent.
|
||||
*
|
||||
* The spec files are living working documents — migration ledgers, scan
|
||||
* archives, change logs and Claude-window handoff notes live next to the
|
||||
* reader-facing spec. The site's audience (PM / devs / new designers) should
|
||||
* only see the spec itself, while the md stays the single source of truth.
|
||||
*
|
||||
* Rules (see also docs-ui-refactor/00-总纲.md §五):
|
||||
* 1. A heading matching HEADING_PATTERNS is dropped together with everything
|
||||
* until the next heading of the same or higher level.
|
||||
* 2. The blockquote directly below the document's first H1 is dropped — by
|
||||
* convention it's working-doc meta (version stamp, 与总纲配套, notes to the
|
||||
* next Claude window), not reader material.
|
||||
* 2b. Standalone `---` section separators are dropped entirely: the rspress
|
||||
* theme already draws a divider before every h2, so the source separators
|
||||
* render as doubled lines — and stripping sections leaves orphaned ones.
|
||||
* 3. Explicit markers for per-file curation:
|
||||
* <!-- site-hide --> hides the next heading's whole section
|
||||
* <!-- site-hide:start --> hides everything until
|
||||
* <!-- site-hide:end -->
|
||||
*/
|
||||
|
||||
const HEADING_PATTERNS = [
|
||||
/改动记录/, // change logs (every spec doc)
|
||||
/关键结论/, // “先读这个” digests addressed at incoming Claude windows
|
||||
/^附录/, // appendices (scan archives, usage ledgers)
|
||||
/^附[::]/, // “附:已迁出本文的内容” style
|
||||
/落地记录/, // implementation logs
|
||||
/给实现窗口/, // sections addressed to the implementing Claude window
|
||||
/待决策清单/, // open-decision checklists for the designer
|
||||
/代码锚点/, // code anchor lists
|
||||
/扫描存档/, // scan archives outside 附录 headings
|
||||
];
|
||||
|
||||
const HIDE_ONE = /^\s*<!--\s*site-hide\s*-->\s*$/;
|
||||
const HIDE_START = /^\s*<!--\s*site-hide:start\s*-->\s*$/;
|
||||
const HIDE_END = /^\s*<!--\s*site-hide:end\s*-->\s*$/;
|
||||
const HEADING = /^(#{1,6})\s+(.+?)\s*$/;
|
||||
const FENCE = /^\s*(```|~~~)/;
|
||||
|
||||
module.exports = function stripInternalSections(source) {
|
||||
const lines = source.split('\n');
|
||||
const out = [];
|
||||
let inFence = false;
|
||||
/** Depth of the heading whose section is being hidden, or null. */
|
||||
let sectionDepth = null;
|
||||
let inRange = false;
|
||||
let hideNextHeading = false;
|
||||
/** 'before-h1' → 'after-h1' (drop a leading meta blockquote) → 'done'. */
|
||||
let metaQuoteState = 'before-h1';
|
||||
|
||||
for (const line of lines) {
|
||||
if (FENCE.test(line)) {
|
||||
// Fences inside hidden regions still toggle so a closing ``` inside a
|
||||
// hidden section doesn't leak fence state to the visible remainder.
|
||||
inFence = !inFence;
|
||||
if (sectionDepth === null && !inRange) out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
if (sectionDepth === null && !inRange) out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HIDE_START.test(line)) {
|
||||
inRange = true;
|
||||
continue;
|
||||
}
|
||||
if (HIDE_END.test(line)) {
|
||||
inRange = false;
|
||||
continue;
|
||||
}
|
||||
if (HIDE_ONE.test(line)) {
|
||||
hideNextHeading = true;
|
||||
continue;
|
||||
}
|
||||
if (inRange) continue;
|
||||
|
||||
// Rule 2b: drop every standalone thematic break (see header).
|
||||
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) continue;
|
||||
|
||||
// Drop the meta blockquote that sits directly under the first H1.
|
||||
if (metaQuoteState === 'after-h1') {
|
||||
if (line.trim() === '') {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (line.trimStart().startsWith('>')) {
|
||||
continue; // swallow the whole leading blockquote
|
||||
}
|
||||
metaQuoteState = 'done'; // first real content — stop looking
|
||||
}
|
||||
|
||||
const m = line.match(HEADING);
|
||||
if (m) {
|
||||
const depth = m[1].length;
|
||||
if (metaQuoteState === 'before-h1' && depth === 1) metaQuoteState = 'after-h1';
|
||||
if (sectionDepth !== null && depth <= sectionDepth) sectionDepth = null;
|
||||
const isInternal =
|
||||
hideNextHeading || HEADING_PATTERNS.some((p) => p.test(m[2]));
|
||||
hideNextHeading = false;
|
||||
if (sectionDepth === null && isInternal) {
|
||||
sectionDepth = depth;
|
||||
continue;
|
||||
}
|
||||
if (sectionDepth !== null) continue;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sectionDepth !== null) continue;
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as path from 'path';
|
||||
import { defineConfig } from 'rspress/config';
|
||||
import { pluginPreview } from '@rspress/plugin-preview';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
/**
|
||||
* Component-library docs site (rspress).
|
||||
@@ -20,6 +22,14 @@ export default defineConfig({
|
||||
title: 'BiSheng 组件库',
|
||||
description: 'BiSheng client 设计规范 + 组件库',
|
||||
lang: 'zh', // single language — i18n intentionally not enabled (req 5)
|
||||
// No SSG: demos import real app components, whose dependency tree reaches
|
||||
// browser/node-conditional packages (@dicebear/converter resolves its `node`
|
||||
// build under SSR and then needs the native `sharp` / `@resvg/resvg-js`,
|
||||
// which we don't install). `rspress dev` never hit this because it only
|
||||
// client-renders; `rspress build` prerenders and failed. Client-side
|
||||
// rendering is fine for an internal docs site, and it keeps every future
|
||||
// demo immune to the same class of SSR-only resolution breakage.
|
||||
ssg: false,
|
||||
// Point straight at the app's stylesheet (tailwind directives + all design
|
||||
// tokens). A wrapper css with `@import` breaks rspack's cssExtractLoader.
|
||||
globalStyles: path.join(clientSrc, 'style.css'),
|
||||
@@ -27,26 +37,40 @@ export default defineConfig({
|
||||
// req 1: live component preview
|
||||
plugins: [pluginPreview({ previewMode: 'internal' })],
|
||||
|
||||
route: {
|
||||
// 00-总纲 is the Claude-window working charter, not reader material —
|
||||
// keep it out of the site entirely (routes AND search index).
|
||||
exclude: ['**/00-总纲.md'],
|
||||
},
|
||||
|
||||
themeConfig: {
|
||||
// req 6: two top-level sections — 文档 (specs) / 组件 (demos)
|
||||
// NOTE: the demos dir is ASCII (`components/`) on purpose — rspress v1's
|
||||
// client router fails to match nested routes with non-ASCII dir names.
|
||||
nav: [
|
||||
{ text: '文档', link: '/00-总纲' },
|
||||
{ text: '组件', link: '/components/button' },
|
||||
// activeMatch drives the selected state: 组件 owns /components/*,
|
||||
// 文档 owns every other doc route (home included).
|
||||
{ text: '文档', link: '/基础-字体规范', activeMatch: '^/(?!components/)' },
|
||||
{ text: '组件', link: '/components/button', activeMatch: '^/components/' },
|
||||
],
|
||||
sidebar: {
|
||||
// 组件 section — component demos
|
||||
'/components/': [
|
||||
{ text: '组件总览', link: '/components/index' },
|
||||
{ text: 'Typography 字体', link: '/components/typography' },
|
||||
{ text: 'Color 色彩', link: '/components/color' },
|
||||
{ text: 'Button 按钮', link: '/components/button' },
|
||||
{ text: 'Modal 弹窗', link: '/components/modal' },
|
||||
{ text: 'Confirm 二次确认', link: '/components/confirm' },
|
||||
{ text: 'Feedback 点赞点踩', link: '/components/feedback' },
|
||||
{ text: 'Icon 图标', link: '/components/icon' },
|
||||
{ text: 'Illustration 插画', link: '/components/illustration' },
|
||||
],
|
||||
// 文档 section — the existing design-spec markdown (kept flat, not moved)
|
||||
'/': [
|
||||
{
|
||||
text: '设计规范',
|
||||
items: [
|
||||
{ text: '总纲', link: '/00-总纲' },
|
||||
{ text: '字体 Typography', link: '/基础-字体规范' },
|
||||
{ text: '色彩 Color', link: '/基础-色彩规范' },
|
||||
{ text: '多端适配', link: '/基础-多端适配原则' },
|
||||
@@ -68,6 +92,14 @@ export default defineConfig({
|
||||
|
||||
builderConfig: {
|
||||
source: {
|
||||
// The app entry (src/main.jsx) imports this too — react-speech-recognition
|
||||
// (reached via the ~/hooks barrel) needs a global regeneratorRuntime.
|
||||
// rspress-overrides.css: docs-site-only fixes on top of the app css
|
||||
// (restores document flow so the sticky nav works — see file header).
|
||||
preEntry: [
|
||||
'regenerator-runtime/runtime',
|
||||
path.join(__dirname, 'stubs/rspress-overrides.css'),
|
||||
],
|
||||
define: {
|
||||
// vite injects these globals (vite.config define); app code reached via
|
||||
// the `~/utils` barrel reads them at module scope — must exist here too.
|
||||
@@ -95,6 +127,18 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
// The docs build consumes design-token.js through a docs-only Tailwind
|
||||
// config (tailwind.docs.config.cjs) — proving the SSOT drives a real
|
||||
// Tailwind theme while the app's tailwind.config.cjs stays untouched.
|
||||
// Replacing the plugins array here supersedes the app's postcss.config.cjs
|
||||
// (which would otherwise resolve the app config), so Tailwind runs once.
|
||||
postcss: (config: any) => {
|
||||
config.postcssOptions = config.postcssOptions || {};
|
||||
config.postcssOptions.plugins = [
|
||||
tailwindcss(path.join(__dirname, 'tailwind.docs.config.cjs')),
|
||||
autoprefixer(),
|
||||
];
|
||||
},
|
||||
cssLoader: {
|
||||
url: {
|
||||
// Leave root-relative (/workspace/...) and $fonts urls untouched —
|
||||
@@ -102,19 +146,42 @@ export default defineConfig({
|
||||
filter: (url: string) => !url.startsWith('/') && !url.startsWith('$fonts'),
|
||||
},
|
||||
},
|
||||
rspack: {
|
||||
resolve: {
|
||||
// Some ui components reach the `~/utils` barrel, which transitively
|
||||
// imports api modules using Node builtins. Demos never execute those
|
||||
// paths at runtime; stub them out so the browser bundle compiles.
|
||||
fallback: {
|
||||
crypto: false,
|
||||
url: false,
|
||||
fs: false,
|
||||
path: false,
|
||||
stream: false,
|
||||
},
|
||||
},
|
||||
rspack: (config) => {
|
||||
config.resolve = config.resolve || {};
|
||||
// filenamify (ESM, imports node:path — an unbundlable scheme) comes
|
||||
// in via the ~/hooks barrel (usePresets); no demo executes it. The
|
||||
// alias must live at the raw-rspack layer — rsbuild's resolve.alias
|
||||
// did not take effect for this package.
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias as Record<string, unknown>),
|
||||
filenamify: path.join(__dirname, 'stubs/filenamify-stub.ts'),
|
||||
};
|
||||
// Some ui components reach the `~/utils` barrel, which transitively
|
||||
// imports api modules using Node builtins. Demos never execute those
|
||||
// paths at runtime; stub them out so the browser bundle compiles.
|
||||
config.resolve.fallback = {
|
||||
...(config.resolve.fallback as Record<string, unknown>),
|
||||
crypto: false,
|
||||
url: false,
|
||||
fs: false,
|
||||
path: false,
|
||||
stream: false,
|
||||
};
|
||||
// Strip internal working sections (ledgers, change logs, scan
|
||||
// archives) from the spec md BEFORE the MDX compiler runs — a
|
||||
// remark-level strip misses the TOC/search, which rspress extracts
|
||||
// ahead of user remark plugins. Single source of truth stays the md.
|
||||
config.module = config.module || { rules: [] };
|
||||
config.module.rules = config.module.rules || [];
|
||||
config.module.rules.push({
|
||||
// .md only: rspress precompiles .mdx Node-side before webpack loaders
|
||||
// run, and MDX rejects the `<!-- site-hide -->` HTML comments this
|
||||
// loader relies on. Spec .mdx pages are authored reader-clean instead.
|
||||
test: /\.md$/,
|
||||
include: [path.join(__dirname, '../../../docs-ui-refactor')],
|
||||
enforce: 'pre',
|
||||
use: [path.join(__dirname, 'plugins/strip-internal-loader.cjs')],
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HoverCardPortal, HoverCardContent } from '~/components/ui';
|
||||
import './styles.module.css';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type TPluginTooltipProps = {
|
||||
content: string;
|
||||
@@ -12,7 +12,7 @@ function PluginTooltip({ content, position }: TPluginTooltipProps) {
|
||||
<HoverCardContent side={position} className="w-80 ">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||
<div className={styles.tooltipHtml} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
a {
|
||||
/* Scoped to the tooltip's injected HTML. The old bare `a` element selector is
|
||||
NOT hashed by CSS modules — it leaked a global `a { color: white }` into the
|
||||
whole app (and the docs site), turning unstyled links white-on-white in
|
||||
light contexts. */
|
||||
.tooltipHtml a {
|
||||
text-decoration: underline;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* design-token.js — Single Source of Truth (SSOT) for BiSheng client design tokens.
|
||||
*
|
||||
* ONE catalog, consumed by two audiences:
|
||||
* • components — via Tailwind (docs build spreads `tailwindTheme` into theme.extend;
|
||||
* the live app config can adopt the same import when the team migrates).
|
||||
* • docs / md — the spec .mdx pages and component demo pages import the catalogs
|
||||
* below and render every table / swatch straight from this file, so
|
||||
* the written spec can never drift from the values components ship.
|
||||
*
|
||||
* CommonJS (`module.exports`) on purpose: a `.cjs` Tailwind config can `require()` it,
|
||||
* and rspack/vite ESM-interop lets an .mdx page `import tokens from '~/design-token'`.
|
||||
*
|
||||
* ── Naming (req: no unclear numbered names) ──────────────────────────────────────────
|
||||
* Semantic tokens carry ROLE names (text-title / fill-hover …). The old numeric names
|
||||
* (text-1, fill-2 …) are kept as DEPRECATED `legacy` aliases so nothing breaks; the app
|
||||
* migrates to the role names gradually. See MIGRATION at the bottom for the full map.
|
||||
* Primitive ramps that are genuinely a scale (brand-50…900, gray-1…10) stay numbered —
|
||||
* that IS their semantic (lightness step), same convention as Tailwind's own palettes.
|
||||
*
|
||||
* ── Runtime behaviour ────────────────────────────────────────────────────────────────
|
||||
* Themeable / responsive tokens still resolve through CSS custom properties defined in
|
||||
* src/style.css (brand blue⇄green switch; ≤768px type remap). This file owns the token
|
||||
* NAMES + documented values and drives the Tailwind theme keys; the CSS vars remain the
|
||||
* runtime carrier. Regenerating :root from this file is a later step (app migration).
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Typography — 基础-字体规范.md
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const FONT_FAMILY = {
|
||||
base: {
|
||||
token: 'font-family-base',
|
||||
cls: 'font-sans',
|
||||
usage: '全局默认(已写入 body/html,无需显式加类)',
|
||||
stack: [
|
||||
'-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto',
|
||||
'"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"',
|
||||
'"Noto Sans CJK SC"', 'sans-serif',
|
||||
],
|
||||
},
|
||||
mono: {
|
||||
token: 'font-family-mono',
|
||||
cls: 'font-mono',
|
||||
usage: 'ID、代码、日志',
|
||||
stack: ['ui-monospace', '"SF Mono"', '"Cascadia Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
|
||||
},
|
||||
};
|
||||
|
||||
/** Semantic type scale — each entry is a Tailwind fontSize key AND its own weight. */
|
||||
const TYPE_SCALE = [
|
||||
{ name: 'caption', desktop: [12, 20], mobile: [12, 20], weight: 400, cssVar: '--text-caption', leadingVar: '--leading-caption', usage: '时间戳、标签、水印' },
|
||||
{ name: 'body-sm', desktop: [13, 21], mobile: [14, 22], weight: 400, cssVar: '--text-body-sm', leadingVar: '--leading-body-sm', usage: '密集表格、侧栏次要项' },
|
||||
{ name: 'body', desktop: [14, 22], mobile: [16, 24], weight: 400, cssVar: '--text-body', leadingVar: '--leading-body', usage: '正文基准,表单、表格默认' },
|
||||
{ name: 'h4', desktop: [16, 24], mobile: [16, 24], weight: 500, cssVar: '--text-h4', leadingVar: '--leading-h4', usage: '强调正文、四级标题' },
|
||||
{ name: 'h3', desktop: [18, 26], mobile: [17, 25], weight: 500, cssVar: '--text-h3', leadingVar: '--leading-h3', usage: '卡片标题' },
|
||||
{ name: 'h2', desktop: [20, 28], mobile: [18, 26], weight: 500, cssVar: '--text-h2', leadingVar: '--leading-h2', usage: '区块标题' },
|
||||
{ name: 'h1', desktop: [24, 32], mobile: [22, 30], weight: 500, cssVar: '--text-h1', leadingVar: '--leading-h1', usage: '页面标题' },
|
||||
{ name: 'display', desktop: [30, 38], mobile: [26, 34], weight: 500, cssVar: '--text-display', leadingVar: '--leading-display', usage: '大标题、营销场景' },
|
||||
{ name: 'metric', desktop: [36, 44], mobile: [30, 38], weight: 500, cssVar: '--text-metric', leadingVar: '--leading-metric', usage: 'Dashboard 核心指标数字' },
|
||||
];
|
||||
|
||||
const FONT_WEIGHT = [
|
||||
{ name: 'regular', token: 'font-weight-regular', cls: 'font-normal', value: 400, usage: '正文、说明' },
|
||||
{ name: 'medium', token: 'font-weight-medium', cls: 'font-medium', value: 500, usage: '标题、强调、按钮' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Brand ramp — dual theme (基础-色彩规范.md §1). Documented hex per theme;
|
||||
* Tailwind resolves these through --brand-N so they switch blue⇄green at
|
||||
* runtime. Numbered because it is a lightness scale (that is the semantic).
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const BRAND_STEPS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
|
||||
|
||||
const BRAND = {
|
||||
main: '500',
|
||||
accentStep: '700', // darker shade used as the primary-marker accent bar
|
||||
role: {
|
||||
'50': '选中背景', '100': 'filled hover', '200': '触屏 active', '300': '过渡档',
|
||||
'400': 'hover 态', '500': '主色', '600': '按下 active', '700': '深色档',
|
||||
'800': '深色档', '900': '深色档', muted: '低饱和点缀',
|
||||
},
|
||||
blue: { '50': '#E8F3FF', '100': '#BEDAFF', '200': '#94BFFF', '300': '#6AA1FF', '400': '#4080FF', '500': '#165DFF', '600': '#024DE3', '700': '#0239AB', '800': '#042B80', '900': '#051D52', muted: '#5773B4' },
|
||||
green: { '50': '#E4F1E7', '100': '#CCE4D2', '200': '#A3D2B0', '300': '#6FBA85', '400': '#3D9B5C', '500': '#169C47', '600': '#098B35', '700': '#076929', '800': '#074E20', '900': '#063216', muted: '#5C8A77' },
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Neutral primitive — Arco gray 1–10 (§2.1). Numbered = the lightness
|
||||
* scale itself; components consume the semantic layer below, not this.
|
||||
* `channels` = "r g b" for rgb(var(--arco-gray-N)/α).
|
||||
* `darkHex` / `darkChannels` = official @arco-design/color gray.dark ramp
|
||||
* (lightness inverts). Runtime carrier: `.dark` override in src/style.css.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const GRAY = [
|
||||
{ n: 1, hex: '#F7F8FA', channels: '247 248 250', darkHex: '#17171A', darkChannels: '23 23 26', role: 'hover 底' },
|
||||
{ n: 2, hex: '#F2F3F5', channels: '242 243 245', darkHex: '#2E2E30', darkChannels: '46 46 48', role: 'filled 底' },
|
||||
{ n: 3, hex: '#E5E6EB', channels: '229 230 235', darkHex: '#484849', darkChannels: '72 72 73', role: '边框' },
|
||||
{ n: 4, hex: '#C9CDD4', channels: '201 205 212', darkHex: '#5F5F60', darkChannels: '95 95 96', role: '禁用 / 占位' },
|
||||
{ n: 5, hex: '#A9AEB8', channels: '169 174 184', darkHex: '#78787A', darkChannels: '120 120 122', role: '过渡' },
|
||||
{ n: 6, hex: '#86909C', channels: '134 144 156', darkHex: '#929293', darkChannels: '146 146 147', role: '辅助文字' },
|
||||
{ n: 7, hex: '#6B7785', channels: '107 119 133', darkHex: '#ABABAC', darkChannels: '171 171 172', role: '过渡' },
|
||||
{ n: 8, hex: '#4E5969', channels: '78 89 105', darkHex: '#C5C5C5', darkChannels: '197 197 197', role: '次文字' },
|
||||
{ n: 9, hex: '#272E3B', channels: '39 46 59', darkHex: '#DFDFDF', darkChannels: '223 223 223', role: '过渡' },
|
||||
{ n: 10, hex: '#1D2129', channels: '29 33 41', darkHex: '#F6F6F6', darkChannels: '246 246 246', role: '主文字' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Semantic layer (§2.2) — ROLE names (canonical) + numeric `legacy` alias.
|
||||
* `cssVar` is the existing runtime carrier in src/style.css.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
// Role names avoid the taken shadcn keys (text-primary/secondary/tertiary);
|
||||
// intensity ramp strong → muted → hint → disabled maps gray-10 → 8 → 6 → 4.
|
||||
// `hex` = light value; `darkHex` = same gray ref resolved on the dark ramp.
|
||||
const TEXT = [
|
||||
{ name: 'strong', legacy: '1', cssVar: '--text-1', ref: 'gray-10', hex: '#1D2129', darkHex: '#F6F6F6', usage: '主文字:标题、正文主体' },
|
||||
{ name: 'muted', legacy: '2', cssVar: '--text-2', ref: 'gray-8', hex: '#4E5969', darkHex: '#C5C5C5', usage: '次要文字:次要说明、默认按钮文字' },
|
||||
{ name: 'hint', legacy: '3', cssVar: '--text-3', ref: 'gray-6', hex: '#86909C', darkHex: '#929293', usage: '辅助文字:弱提示、时间戳、占位符' },
|
||||
{ name: 'disabled', legacy: '4', cssVar: '--text-4', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '禁用文字' },
|
||||
];
|
||||
|
||||
const FILL = [
|
||||
{ name: 'subtle', legacy: '1', cssVar: '--fill-1', ref: 'gray-1', hex: '#F7F8FA', darkHex: '#17171A', usage: '浅填充:hover 底、页面浅灰背景' },
|
||||
{ name: 'default', legacy: '2', cssVar: '--fill-2', ref: 'gray-2', hex: '#F2F3F5', darkHex: '#2E2E30', usage: '填充:active 底、filled 控件底' },
|
||||
{ name: 'hover', legacy: '3', cssVar: '--fill-3', ref: 'gray-3', hex: '#E5E6EB', darkHex: '#484849', usage: '深填充:filled hover' },
|
||||
{ name: 'active', legacy: '4', cssVar: '--fill-4', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '重填充:filled active' },
|
||||
];
|
||||
|
||||
const BORDER = [
|
||||
{ name: 'base', cssVar: '--border-base', ref: 'gray-3', hex: '#E5E6EB', darkHex: '#484849', usage: '常规边框:输入框、卡片、分割线' },
|
||||
{ name: 'deep', cssVar: '--border-deep', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '深边框:强调分割、hover 边框' },
|
||||
];
|
||||
|
||||
/* Background surfaces — the "white that darkens" family. The gray ramp starts
|
||||
* at gray-1 (#F7F8FA), so the pure page surface needs its own semantic token
|
||||
* (the fixed `--white: #fff` legacy var never theme-flips — different job).
|
||||
* Carrier: --bg-page in src/style.css (:root + .dark override). */
|
||||
const BG = [
|
||||
{ name: 'page', cssVar: '--bg-page', hex: '#FFFFFF', darkHex: '#121212', usage: '页面底色:内容区、顶栏等最底层表面' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Functional colors (§3) — fixed hex, never theme-switched.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const FUNCTIONAL = [
|
||||
{ name: 'success', label: '成功 Success', cssVar: '--success', main: '#00B42A', hover: '#23C343', active: '#009A29', tint: '#E8FFEA' },
|
||||
{ name: 'warning', label: '警告 Warning', cssVar: '--warning', main: '#FF7D00', hover: '#FF9A2E', active: '#D25F00', tint: '#FFF7E8' },
|
||||
{ name: 'danger', label: '危险 Danger', cssVar: '--danger', main: '#F53F3F', hover: '#D6373A', active: '#D02F33', tint: '#FFECE8' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Tag pairs (§4) — light bg + strong text. Purple / approving-blue are
|
||||
* intentional fixed exceptions (not tokenized, never theme-switched).
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const TAG = [
|
||||
{ label: '技能(紫 · 未 token 化)', bg: '#F5E8FF', fg: '#722ED1', note: '固定例外色' },
|
||||
{ label: '助手(橙 = warning 同值)', bg: '#FFF7E8', fg: '#FF7D00', note: 'warning tint' },
|
||||
{ label: '已完成', bg: '#E8FFEA', fg: '#00B42A', note: 'success tint' },
|
||||
{ label: '已驳回', bg: '#FFECE8', fg: '#F53F3F', note: 'danger tint' },
|
||||
{ label: '审批中(例外:永远蓝)', bg: '#E8F3FF', fg: '#165DFF', note: '固定蓝,不换肤' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Radius (01-设计规范.md §1) & icon sizes (基础-图标规范.md §3.2)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const RADIUS = [
|
||||
{ name: 'sm', px: 4, usage: 'small 控件 / 表格行内按钮' },
|
||||
{ name: 'md', px: 6, usage: 'medium 控件(默认)' },
|
||||
{ name: 'lg', px: 8, usage: 'large 控件 / --radius 基准' },
|
||||
{ name: 'xl2', px: 16, cls: 'rounded-2xl', usage: '弹窗 / 大卡片容器' },
|
||||
];
|
||||
|
||||
const ICON_SIZE = [
|
||||
{ name: 'xs', px: 12, strokeWidth: 2.5, usage: '极小标记(badge、密集表格角标),仅纯展示' },
|
||||
{ name: 'sm', px: 14, usage: 'small / medium 按钮的文字+icon' },
|
||||
{ name: 'md', px: 16, usage: '默认:图标按钮、菜单项、输入框内、表格操作' },
|
||||
{ name: 'lg', px: 20, usage: '导航栏、侧边栏入口、页头操作' },
|
||||
{ name: 'xl', px: 24, usage: '独立展示、弹窗标题图标(原始画布尺寸)' },
|
||||
{ name: 'xl2', px: 32, strokeWidth: 1.5, usage: '超大展示:空状态、引导页' },
|
||||
];
|
||||
|
||||
/* ================================================================== *
|
||||
* Derived: Tailwind theme fragment — spread into theme.extend.
|
||||
* Channel-triplet form keeps `/<alpha>` opacity modifiers working.
|
||||
* ================================================================== */
|
||||
|
||||
const withAlpha = (cssVar) => `rgb(var(${cssVar}) / <alpha-value>)`;
|
||||
|
||||
/** Color keys embed their category (text-/fill-/border-) so a single utility reads right. */
|
||||
const colors = {};
|
||||
TEXT.forEach((t) => {
|
||||
colors[`text-${t.name}`] = withAlpha(t.cssVar); // canonical role name
|
||||
colors[`text-${t.legacy}`] = withAlpha(t.cssVar); // DEPRECATED alias (kept for migration)
|
||||
});
|
||||
FILL.forEach((f) => {
|
||||
colors[`fill-${f.name}`] = withAlpha(f.cssVar);
|
||||
colors[`fill-${f.legacy}`] = withAlpha(f.cssVar); // DEPRECATED alias
|
||||
});
|
||||
BORDER.forEach((b) => {
|
||||
colors[`border-${b.name}`] = withAlpha(b.cssVar);
|
||||
});
|
||||
BG.forEach((b) => {
|
||||
colors[`bg-${b.name}`] = withAlpha(b.cssVar); // class: bg-bg-page
|
||||
});
|
||||
FUNCTIONAL.forEach((fn) => {
|
||||
colors[fn.name] = {
|
||||
DEFAULT: withAlpha(fn.cssVar),
|
||||
hover: withAlpha(`${fn.cssVar}-hover`),
|
||||
active: withAlpha(`${fn.cssVar}-active`),
|
||||
tint: withAlpha(`${fn.cssVar}-tint`),
|
||||
};
|
||||
});
|
||||
|
||||
const fontSize = {};
|
||||
TYPE_SCALE.forEach((s) => {
|
||||
fontSize[s.name] = [`var(${s.cssVar})`, { lineHeight: `var(${s.leadingVar})`, fontWeight: String(s.weight) }];
|
||||
});
|
||||
|
||||
const tailwindTheme = { colors, fontSize };
|
||||
|
||||
/* ================================================================== *
|
||||
* Migration map — old numeric class → new role class, for the app's
|
||||
* gradual adoption (both resolve identically until the old alias is
|
||||
* removed). Emitted as data so tooling / codemods can read it.
|
||||
* ================================================================== */
|
||||
|
||||
const MIGRATION = [
|
||||
...TEXT.map((t) => ({ from: `text-text-${t.legacy}`, to: `text-text-${t.name}`, cssVar: t.cssVar })),
|
||||
...FILL.map((f) => ({ from: `bg-fill-${f.legacy}`, to: `bg-fill-${f.name}`, cssVar: f.cssVar })),
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
FONT_FAMILY,
|
||||
TYPE_SCALE,
|
||||
FONT_WEIGHT,
|
||||
BRAND,
|
||||
BRAND_STEPS,
|
||||
GRAY,
|
||||
TEXT,
|
||||
FILL,
|
||||
BORDER,
|
||||
BG,
|
||||
FUNCTIONAL,
|
||||
TAG,
|
||||
RADIUS,
|
||||
ICON_SIZE,
|
||||
tailwindTheme,
|
||||
MIGRATION,
|
||||
};
|
||||
@@ -60,7 +60,7 @@ const SPEC_PAGES: PageDef[] = [
|
||||
|
||||
/** Progress registry — migration dashboards & ledgers, one per in-flight component. */
|
||||
const PROGRESS_PAGES: PageDef[] = [
|
||||
{ id: 'overview', label: '总览看板', group: '总则', Page: ProgressOverview },
|
||||
{ id: 'overview', label: '现状总览', group: '总则', Page: ProgressOverview },
|
||||
{ id: 'typography', label: '字体 Typography', group: '基础 Foundation', status: 'wip', Page: TypographyProgress },
|
||||
{ id: 'color', label: '色彩 Colors', group: '基础 Foundation', status: 'wip', Page: ColorProgress },
|
||||
{ id: 'button', label: 'Button 按钮', group: '通用 General', status: 'wip', Page: ButtonProgress },
|
||||
@@ -68,9 +68,11 @@ const PROGRESS_PAGES: PageDef[] = [
|
||||
{ id: 'confirm', label: '二次确认弹窗', group: '反馈 Feedback', status: 'wip', Page: ConfirmProgress },
|
||||
];
|
||||
|
||||
const REGISTRY: Record<Mode, PageDef[]> = { spec: SPEC_PAGES, progress: PROGRESS_PAGES };
|
||||
/* Order matters — Object.keys drives the segment control, and 现状梳理 is the
|
||||
gallery's primary job (the written spec now lives in the separate rspress site). */
|
||||
const REGISTRY: Record<Mode, PageDef[]> = { progress: PROGRESS_PAGES, spec: SPEC_PAGES };
|
||||
|
||||
const MODE_LABEL: Record<Mode, string> = { spec: '设计规范', progress: '迁移进度' };
|
||||
const MODE_LABEL: Record<Mode, string> = { progress: '现状梳理', spec: '设计规范' };
|
||||
|
||||
const STATUS_DOT: Record<Status, string> = {
|
||||
wip: 'bg-amber-500',
|
||||
@@ -87,7 +89,7 @@ function groupsOf(pages: PageDef[]): string[] {
|
||||
}
|
||||
|
||||
export default function GalleryApp() {
|
||||
const [mode, setMode] = useState<Mode>('spec');
|
||||
const [mode, setMode] = useState<Mode>('progress');
|
||||
/* Remember the active page per mode, so toggling back restores where you were. */
|
||||
const [activeIds, setActiveIds] = useState<Record<Mode, string>>({
|
||||
spec: 'overview',
|
||||
|
||||
@@ -11,30 +11,71 @@ import { ComponentPage, ExampleGroup, CompareTable } from '../components/kit';
|
||||
export function ButtonProgress() {
|
||||
return (
|
||||
<ComponentPage
|
||||
title="Button · 迁移"
|
||||
eng="Button Progress"
|
||||
title="Button 按钮 · 现状"
|
||||
eng="Button Inventory"
|
||||
description={
|
||||
<>
|
||||
基准组件已重构落地(2026-07-14):旧入参自动映射为新双轴(已标
|
||||
deprecated),业务零改动先跑起来,逐批迁移后删除映射层。剩余工作:设计师验收推导值 →
|
||||
逐批迁移业务页 → 清退 <code>btn</code> 系全局类与 Generations/Button。
|
||||
2026-07-20 全仓重扫。<b>真正的大头不是 Button 组件,而是野生原生按钮</b>:
|
||||
<code><Button></code> 264 处,而手写 <code><button></code> 有 485 处 ——
|
||||
后者比前者还多,且完全不受设计系统约束。
|
||||
</>
|
||||
}
|
||||
whenToUse={[
|
||||
<>
|
||||
缺省高度 <code>h-9</code>(36px) 已归入 medium(32px),<b>全站矮 4px</b>
|
||||
,迁移各批带目检回归(§6.6)。
|
||||
新双轴 API(<code>color</code> 属性)业务里<b>仅 1 处</b>使用 ——
|
||||
旧入参虽已自动映射为新双轴,但业务代码基本没迁。
|
||||
</>,
|
||||
<>
|
||||
缺省高度 <code>h-9</code>(36px) 已归入 medium(32px),全站矮 4px,迁移各批带目检回归。
|
||||
</>,
|
||||
<>下表右列全部用旧 API 渲染 —— 外观应与新双轴一致,即映射验收。</>,
|
||||
]}
|
||||
bodyTitle="迁移台账"
|
||||
bodyTitle="现状盘点"
|
||||
>
|
||||
<ExampleGroup title="旧 API 兼容映射(迁移台账,§6.3)">
|
||||
<ExampleGroup
|
||||
title="① 按钮总盘"
|
||||
subtitle="全站到底有多少种“按钮”——组件化的只是其中一部分。"
|
||||
>
|
||||
<CompareTable
|
||||
head={['来源', '判定方式', '出现次数', '涉及文件', '受设计系统约束?']}
|
||||
rows={[
|
||||
[
|
||||
<b key="a">原生 <button></b>,
|
||||
<code key="a2"><button</code>,
|
||||
<b key="a3">485</b>,
|
||||
<b key="a4">222</b>,
|
||||
'❌ 完全不受约束,样式各写各的',
|
||||
],
|
||||
[
|
||||
'Button 组件',
|
||||
<code key="b"><Button></code>,
|
||||
'264',
|
||||
'136',
|
||||
'✅ 走 cva 档位(但 96% 仍用旧 API 入参)',
|
||||
],
|
||||
[
|
||||
'全局 CSS 类',
|
||||
<code key="c">btn / btn-primary / btn-neutral / btn-secondary</code>,
|
||||
'—',
|
||||
'约 10',
|
||||
'❌ LibreChat 遗留全局类:ToolItem、EditMessage、EditTextPart、HeaderNewChat、PluginStoreItem…',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<p className="mt-3 text-body-sm text-muted-foreground">
|
||||
按钮统一的真实工作量在第一行 —— 485 处原生 <code><button></code>
|
||||
里,哪些该收进 Button 组件、哪些本就该是无样式的可点区域(icon/关闭/列表项),需要逐类甄别。
|
||||
</p>
|
||||
</ExampleGroup>
|
||||
|
||||
<ExampleGroup
|
||||
title="② 旧 API 兼容映射"
|
||||
subtitle="旧入参自动映射为新双轴(已标 deprecated);右列全部用旧 API 渲染,外观应与新双轴一致。"
|
||||
>
|
||||
<CompareTable
|
||||
head={['旧写法(用量)', '映射为', '旧 API 实渲染']}
|
||||
rows={[
|
||||
[
|
||||
<code key="o">缺省 / variant="default"(116 处)</code>,
|
||||
<code key="o">缺省 + 显式 default(98 + 7 = 105 处)</code>,
|
||||
<code key="n">primary solid</code>,
|
||||
<Button key="b" variant="default">
|
||||
按钮
|
||||
@@ -48,14 +89,14 @@ export function ButtonProgress() {
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="outline"(78 处)</code>,
|
||||
<code key="o">variant="outline"(77 处)</code>,
|
||||
<code key="n">default outlined</code>,
|
||||
<Button key="b" variant="outline">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="secondary"(17 处,原 18:知识空间侧栏“创建知识空间”已迁 primary filled)</code>,
|
||||
<code key="o">variant="secondary"(17 处)</code>,
|
||||
<code key="n">default filled</code>,
|
||||
<Button key="b" variant="secondary">
|
||||
按钮
|
||||
@@ -83,14 +124,14 @@ export function ButtonProgress() {
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="link"(0 处)</code>,
|
||||
<code key="o">variant="link" / secondaryBrand(0 处)</code>,
|
||||
<code key="n">primary link</code>,
|
||||
<Button key="b" variant="link">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">size 缺省 / "sm"(249 处,旧 h-9)</code>,
|
||||
<code key="o">size="sm"(48 处,旧 h-9)</code>,
|
||||
<code key="n">medium(32px)</code>,
|
||||
<Button key="b" variant="outline" size="sm">
|
||||
按钮
|
||||
@@ -104,7 +145,7 @@ export function ButtonProgress() {
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">size="icon"(18 处)</code>,
|
||||
<code key="o">size="icon"(17 处)</code>,
|
||||
<code key="n">medium + iconOnly</code>,
|
||||
<Button key="b" variant="outline" size="icon" aria-label="搜索">
|
||||
<Outlined.Search />
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ComponentPage, ExampleGroup, CompareTable } from '../components/kit';
|
||||
export function ColorProgress() {
|
||||
return (
|
||||
<ComponentPage
|
||||
title="色彩 · 迁移"
|
||||
eng="Color Progress"
|
||||
title="色彩 · 现状"
|
||||
eng="Color Inventory"
|
||||
description={
|
||||
<>
|
||||
两层 token + Tailwind 接线已落地(2026-07-15)。现状:裸 hex <b>2469 处 / 215 个值</b>
|
||||
|
||||
@@ -61,22 +61,21 @@ function ConfirmDemo({
|
||||
export function ConfirmProgress() {
|
||||
return (
|
||||
<ComponentPage
|
||||
title="二次确认 · 迁移"
|
||||
eng="Confirm Progress"
|
||||
title="二次确认 · 现状"
|
||||
eng="Confirm Inventory"
|
||||
description={
|
||||
<>
|
||||
两套体系:旧页面走 <code>OGDialogTemplate selection</code>(剩 13 文件:7 处死 UI 确认 +
|
||||
6 处表单弹窗),新页面走 <code>useConfirm()</code>(26 文件,含已迁入的 10 处)。
|
||||
<b>用户可见的真确认已全部迁完</b>;死 UI(被注释的 SidePanel 树 + 无人引用的
|
||||
Chat/Header 树)待死代码清理,表单弹窗归 Modal 期。<b>收敛第一步已完成</b>:B
|
||||
套壳与按钮已对齐 C 套,历史 9 种 selectClasses 被自动折叠为 danger / primary 两档 ——
|
||||
下方旧写法卡片现在应呈现统一外观,逐个打开即是验收。 (另有 9 个文件手拼{' '}
|
||||
<code>AlertDialog</code> —— 属于普通弹窗,归 Modal 改造范围,本期不动。)
|
||||
两套体系并行:旧页面走 <code>OGDialogTemplate selection</code>(<b>13 处</b>旧
|
||||
selectClasses),新页面走 <code>useConfirm()</code>(<b>26 个文件</b>)。B 套壳与按钮
|
||||
已在组件内部对齐 C 套,外观已统一 —— 但<b>业务代码一处都没迁</b>。
|
||||
</>
|
||||
}
|
||||
whenToUse={[
|
||||
<>剩余工作:死 UI 清理(selectClasses ①②③ 全在死树);宽度与 Loading 待定项随第三步迁移清理。</>,
|
||||
<>画廊卫生:某写法业务清零后删卡片,清单表留「0(原 N)✅」台账。</>,
|
||||
<>
|
||||
新档位 <code>selectVariant</code> 业务使用数为 <b>0</b>;折叠靠的是{' '}
|
||||
<code>OGDialogTemplate</code> 内部的旧 class 自动映射。
|
||||
</>,
|
||||
<>下方卡片全部用旧 selectClasses 渲染 —— 外观应已统一,逐个打开即是验收。</>,
|
||||
]}
|
||||
bodyTitle="现状盘点"
|
||||
>
|
||||
@@ -90,16 +89,16 @@ export function ConfirmProgress() {
|
||||
<>
|
||||
<code>OGDialogTemplate</code> + <code>selection</code>
|
||||
</>,
|
||||
'13(原 21;剩余全是死 UI 或表单)',
|
||||
'13 处旧 selectClasses',
|
||||
'旧页面(会话/书签/Agent/设置/Prompt…LibreChat 血统)',
|
||||
'差 · 确认按钮 9 种写法',
|
||||
'差 · 6 种历史写法',
|
||||
],
|
||||
[
|
||||
'C 套服务',
|
||||
<>
|
||||
<code>useConfirm()</code>(ConfirmContext + AlertDialog)
|
||||
</>,
|
||||
'26(收敛完成,含已迁入 10 处)',
|
||||
'26',
|
||||
'新页面(知识空间 / 订阅频道 / 权限)',
|
||||
'好 · 样式集中在一个文件,destructive/default 两档',
|
||||
],
|
||||
@@ -108,77 +107,70 @@ export function ConfirmProgress() {
|
||||
</ExampleGroup>
|
||||
|
||||
{/* Inventory table: every selectClasses variant found in business code */}
|
||||
<ExampleGroup title="确认按钮 selectClasses 清单(9 种历史写法)">
|
||||
<ExampleGroup
|
||||
title="确认按钮 selectClasses 清单"
|
||||
subtitle="2026-07-20 实测:业务里仍有 13 处传旧 selectClasses;新档位 selectVariant 使用数为 0。"
|
||||
>
|
||||
<CompareTable
|
||||
head={['#', '确认按钮 selectClasses(原文)', '用处', '文件数']}
|
||||
head={['#', '确认按钮 selectClasses(原文)', '用处', '出现处']}
|
||||
rows={[
|
||||
[
|
||||
'1',
|
||||
<code key="c">bg-red-700 dark:bg-red-600 hover:bg-red-800 …</code>,
|
||||
'可达的 4 处已迁 C;剩 4 处全是死 UI(书签/分享弹窗/两个工具移除)',
|
||||
'4(原 8)· 全死 UI',
|
||||
'Agents/Builder 的 ActionsPanel×2、AgentTool、AssistantTool、删除书签、分享链接',
|
||||
<b key="n">6</b>,
|
||||
],
|
||||
[
|
||||
'2',
|
||||
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-800</code>,
|
||||
'删除 Agent / Assistant —— 死 UI(SidePanel 被注释)',
|
||||
'2(原 3)· 全死 UI',
|
||||
'删除 Agent / Assistant',
|
||||
'2',
|
||||
],
|
||||
[
|
||||
'3',
|
||||
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-600</code>,
|
||||
'清空预设 —— 死 UI(Chat/Header 无人引用)',
|
||||
'1 · 死 UI',
|
||||
'清空预设',
|
||||
'1',
|
||||
],
|
||||
[
|
||||
'4',
|
||||
<code key="c">bg-destructive hover:bg-destructive/80</code>,
|
||||
'清空聊天 / 删缓存 / 撤销密钥 —— ✅ 已全部迁 C 套',
|
||||
'0(原 3)',
|
||||
],
|
||||
[
|
||||
'5',
|
||||
<code key="c">bg-surface-destructive hover:bg-surface-destructive-hover</code>,
|
||||
'删除版本 / 管理员确认 —— ✅ 已全部迁 C 套',
|
||||
'0(原 2)',
|
||||
],
|
||||
[
|
||||
'6',
|
||||
<code key="c">bg-green-500 hover:bg-green-600 text-white</code>,
|
||||
<code key="c">bg-green-500 hover:bg-green-600 …</code>,
|
||||
'保存预设 / 保存 API Key(确认=绿色?!)',
|
||||
'2',
|
||||
],
|
||||
[
|
||||
'7',
|
||||
'5',
|
||||
<>
|
||||
<code>btn btn-primary</code>(全局 CSS 类)
|
||||
</>,
|
||||
'提交密钥',
|
||||
'提交密钥 SetKeyDialog',
|
||||
'1',
|
||||
],
|
||||
[
|
||||
'8',
|
||||
'6',
|
||||
<code key="c">bg-surface-submit hover:bg-surface-submit-hover</code>,
|
||||
'重命名保存',
|
||||
'重命名保存 DashGroupItem',
|
||||
'1',
|
||||
],
|
||||
[
|
||||
'9',
|
||||
<>
|
||||
(不传 → 模板默认)<code>bg-gray-800 … dark:bg-gray-200</code>
|
||||
</>,
|
||||
'OGDialogTemplate 内置 defaultSelect',
|
||||
'—',
|
||||
<code key="c">bg-destructive / bg-surface-destructive 系</code>,
|
||||
'清空聊天 / 删缓存 / 撤销密钥 / 删除版本 / 管理员确认 —— ✅ 已全部迁 C 套',
|
||||
'0(原 5)',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<p className="mt-3 text-body-sm text-muted-foreground">
|
||||
折叠是在 <code>OGDialogTemplate</code> 内部做的(旧 class 自动映射为 danger / primary
|
||||
两档),所以外观已统一,但<b>业务代码一处都没迁</b> —— 上面 13 处仍是旧写法。
|
||||
</p>
|
||||
</ExampleGroup>
|
||||
|
||||
<ExampleGroup title="逐个打开对比(旧写法现应呈现统一外观)">
|
||||
<DemoGrid cols={3}>
|
||||
<ConfirmDemo
|
||||
label="① red-700 系(6 处,原 8)"
|
||||
note="删除书签 — Bookmarks/DeleteBookmarkButton.tsx(原例子删会话已迁 C 套)"
|
||||
label="① red-700 系(6 处)"
|
||||
note="删除书签 · 分享链接 · Agents/Builder 工具移除"
|
||||
title="删除书签"
|
||||
body={
|
||||
<>
|
||||
@@ -189,7 +181,7 @@ export function ConfirmProgress() {
|
||||
selectClasses="bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white"
|
||||
/>
|
||||
<ConfirmDemo
|
||||
label="② red-600 系(2 处,原 3)"
|
||||
label="② red-600 系(2 处)"
|
||||
note="删除 Agent — SidePanel/Agents/DeleteButton.tsx"
|
||||
title="删除助手"
|
||||
body="确定要删除这个助手吗?此操作不可撤销。"
|
||||
@@ -197,7 +189,7 @@ export function ConfirmProgress() {
|
||||
selectClasses="bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white"
|
||||
/>
|
||||
<ConfirmDemo
|
||||
label="⑥ 绿色确认(2 处)"
|
||||
label="④ 绿色确认(2 处)"
|
||||
note="保存预设 — Endpoints/SaveAsPresetDialog.tsx"
|
||||
title="另存为预设"
|
||||
body="将当前配置保存为预设?"
|
||||
@@ -206,7 +198,7 @@ export function ConfirmProgress() {
|
||||
showCloseButton
|
||||
/>
|
||||
<ConfirmDemo
|
||||
label="⑨ 模板默认(不传 selectClasses)"
|
||||
label="模板默认(不传 selectClasses)"
|
||||
note="OGDialogTemplate 内置 defaultSelect:黑底/暗色反白"
|
||||
title="确认操作"
|
||||
body="这是不传 selectClasses 时的默认确认按钮。"
|
||||
@@ -214,7 +206,7 @@ export function ConfirmProgress() {
|
||||
/>
|
||||
<ConfirmDemo
|
||||
label="Loading 态(isLoading: true)"
|
||||
note="模板内置 Spinner · 各页自塞 Spinner 的写法已随迁移清零(原 4 处)"
|
||||
note="模板内置 Spinner"
|
||||
title="删除会话"
|
||||
body="确认按钮处于加载中。"
|
||||
selectText="删除"
|
||||
@@ -222,7 +214,7 @@ export function ConfirmProgress() {
|
||||
isLoading
|
||||
/>
|
||||
<ConfirmDemo
|
||||
label="⑧ surface-submit(1 处)"
|
||||
label="⑥ surface-submit(1 处)"
|
||||
note="重命名保存 — Prompts/Groups/DashGroupItem.tsx"
|
||||
title="重命名"
|
||||
body="保存新的名称?"
|
||||
@@ -230,7 +222,7 @@ export function ConfirmProgress() {
|
||||
selectClasses="bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit"
|
||||
/>
|
||||
<ConfirmDemo
|
||||
label="⑦ btn btn-primary(1 处)"
|
||||
label="⑤ btn btn-primary(1 处)"
|
||||
note="提交密钥 — SetKeyDialog.tsx · 走全局 CSS 类"
|
||||
title="设置密钥"
|
||||
body="提交这个 API Key?"
|
||||
|
||||
@@ -70,65 +70,72 @@ function ConfirmReferenceDemo() {
|
||||
export function ModalProgress() {
|
||||
return (
|
||||
<ComponentPage
|
||||
title="Modal · 迁移"
|
||||
eng="Modal Progress"
|
||||
title="Modal 弹窗 · 现状"
|
||||
eng="Modal Inventory"
|
||||
description={
|
||||
<>
|
||||
2026-07-09 重新盘点:全站普通弹窗共 <b>5 个并行体系、约 64 个业务文件</b>
|
||||
。二次确认期已把 B 套壳对齐 C 套视觉,但 A 套(原语直接拼 22 处 + 模板 3
|
||||
处)与手拼 AlertDialog(7 处)仍是另外两种壳。逐个打开对比,定统一标准。
|
||||
2026-07-20 全仓重扫:弹窗类业务文件<b>去重 63 个</b>,分属 <b>5 个并行壳体系</b>
|
||||
。二次确认期已把 B 套壳对齐 C 套视觉,A 套(原语 23 + 模板 3)与手拼 AlertDialog(6)
|
||||
仍是另外两种壳。逐个打开对比,定统一标准。
|
||||
</>
|
||||
}
|
||||
whenToUse={[
|
||||
<>本页是<b>现状盘点</b>,统一标准尚未定稿(见文末"④ 待设计师定夺")。</>,
|
||||
<>
|
||||
按血统分:LibreChat 血统 <b>24</b> · 毕昇自研 <code>pages/</code> <b>15</b> ·
|
||||
SidePanel 子树 <b>10</b> · 其它 14。
|
||||
</>,
|
||||
<>已定:二次确认(C 套)壳为基准候选——圆角 16 / p-5 / 灰底毛玻璃;B 套已对齐。</>,
|
||||
<>待定:A 套(最大人群 22 处)的遮罩 / 圆角 / 标题字重是否并入基准。</>,
|
||||
<>待定:A 套(最大人群 23 处)的遮罩 / 圆角 / 标题字重是否并入基准(见文末"④ 待定夺")。</>,
|
||||
]}
|
||||
bodyTitle="现状盘点"
|
||||
>
|
||||
{/* ① Population overview */}
|
||||
<ExampleGroup title="① 用量盘子(当前精确扫描,排除 ui/ 与画廊)">
|
||||
<ExampleGroup title="① 用量盘子" subtitle="2026-07-20 全仓扫描,排除 ui/ 定义、画廊与测试文件。">
|
||||
<CompareTable
|
||||
head={['体系', '实现', '业务文件数', '用在哪 / 备注']}
|
||||
head={['体系', '判定方式', '业务文件数', '用在哪 / 备注']}
|
||||
rows={[
|
||||
[
|
||||
'A 套 · 原语直接拼',
|
||||
'Dialog + DialogContent 手拼',
|
||||
<b key="a1">22(最大人群)</b>,
|
||||
'新页面为主:知识库 8、订阅 2、审批/通知/账号/分享/appChat…(含 MainLayout 全局弹窗)',
|
||||
<code key="a"><DialogContent</code>,
|
||||
<b key="a1">23(最大人群)</b>,
|
||||
'知识库 8、订阅 3、审批/通知/账号/分享/appChat、MainLayout 全局弹窗…',
|
||||
],
|
||||
[
|
||||
'A 套 · 模板',
|
||||
'DialogTemplate',
|
||||
<code key="b"><DialogTemplate</code>,
|
||||
'3',
|
||||
'EditPresetDialog、PresetItems、ContextButton(后者在 SidePanel 死树)',
|
||||
'EditPresetDialog、PresetItems、ContextButton(后者 0 引用,确认死代码)',
|
||||
],
|
||||
[
|
||||
'B 套 · 模板',
|
||||
'OGDialogTemplate',
|
||||
'16(原 25,确认迁移后)',
|
||||
'书签/导出/SetKey/归档/Agent 面板…(其中 SidePanel 死树约 6 处);壳已对齐 C 套',
|
||||
<code key="c"><OGDialogTemplate</code>,
|
||||
'16',
|
||||
'书签/导出/SetKey/归档/Agent 面板…;壳已对齐 C 套',
|
||||
],
|
||||
[
|
||||
'B 套 · 原语直接拼',
|
||||
'OGDialog + OGDialogContent 手拼',
|
||||
<code key="d"><OGDialogContent</code>,
|
||||
'16',
|
||||
'设置(账号/数据)、Prompts、文件预览、ShareAgent…;壳同上(已对齐 C 套)',
|
||||
'设置(账号/数据)、Prompts、文件预览、ShareAgent…;壳同上',
|
||||
],
|
||||
[
|
||||
'手拼 AlertDialog',
|
||||
'AlertDialogContent + 自拼头尾',
|
||||
'7',
|
||||
'频道成员 2、爬取系 4、灵思 TaskModeInput(部分带确认性质,本期一并处理)',
|
||||
<code key="e"><AlertDialogContent</code>,
|
||||
'6',
|
||||
'频道成员 2、爬取系 3、灵思 TaskModeInput(部分带确认性质)',
|
||||
],
|
||||
[
|
||||
'C 套 · useConfirm(参照)',
|
||||
'ConfirmContext(AlertDialog 底层)',
|
||||
<code key="f">useConfirm()</code>,
|
||||
'26(已收敛 ✅)',
|
||||
'二次确认已定稿的视觉基准:圆角16 / p-5 / 灰底毛玻璃 —— Modal 壳的天然候选',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<p className="mt-3 text-body-sm text-muted-foreground">
|
||||
注:<code>AlertDialogContent</code> 另有 1 处在 <code>Providers/ConfirmContext.tsx</code>
|
||||
—— 那是 C 套自己的实现,不计入业务手拼。
|
||||
</p>
|
||||
</ExampleGroup>
|
||||
|
||||
{/* ② Shell anatomy */}
|
||||
@@ -189,7 +196,7 @@ export function ModalProgress() {
|
||||
<ExampleGroup title="③ 同一内容装进各个壳(逐个打开对比)">
|
||||
<DemoGrid cols={3}>
|
||||
{/* A-set raw primitives — the largest population */}
|
||||
<Demo label="A 套 · 原语直接拼(22 处)" note="ui/Dialog.tsx · 浅黑毛玻璃 · 圆角8 · p-5">
|
||||
<Demo label="A 套 · 原语直接拼(23 处)" note="ui/Dialog.tsx · 浅黑毛玻璃 · 圆角8 · p-5">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">打开</Button>
|
||||
@@ -267,7 +274,7 @@ export function ModalProgress() {
|
||||
|
||||
{/* Hand-rolled AlertDialog population */}
|
||||
<Demo
|
||||
label="手拼 AlertDialog(7 处)"
|
||||
label="手拼 AlertDialog(6 处)"
|
||||
note="p-6 · 圆角8 · 无边框阴影 · z-110 · 移动端贴底滑入"
|
||||
>
|
||||
<AlertDialog>
|
||||
@@ -324,7 +331,7 @@ export function ModalProgress() {
|
||||
<b>层级</b>:z-50 / z-[100] / z-[110] 三档并存,统一到几?(需盘 Drawer/Sheet/Popover 的层级关系)
|
||||
</li>
|
||||
<li>
|
||||
<b>原语收敛</b>:A 套 22 处直拼是最大人群 —— 是把 A 套壳改成标准(业务零改动),还是逐批迁 B 套?
|
||||
<b>原语收敛</b>:A 套 23 处直拼是最大人群 —— 是把 A 套壳改成标准(业务零改动),还是逐批迁 B 套?
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,12 @@ import { ComponentPage, ExampleGroup, CompareTable } from '../components/kit';
|
||||
export function ProgressOverview() {
|
||||
return (
|
||||
<ComponentPage
|
||||
title="迁移进度"
|
||||
eng="Progress"
|
||||
title="现状总览"
|
||||
eng="Inventory"
|
||||
description={
|
||||
<>
|
||||
组件统一化的现状看板,给改造负责人看。左侧各组件页是对应的迁移账本(用量盘点 /
|
||||
旧写法清单 / 待定项)。只想查规范怎么用?切回顶部的「设计规范」。
|
||||
这个画廊的首要职能是<b>现状梳理</b>——把产品里实际存在的各种弹窗、按钮样式摆出来并排看,
|
||||
才能看清重复与不一致。左侧各页是逐组件的现状账本。文字规范已移至独立的 rspress 文档站。
|
||||
</>
|
||||
}
|
||||
whenToUse={[
|
||||
@@ -26,7 +26,7 @@ export function ProgressOverview() {
|
||||
]}
|
||||
bodyTitle={null}
|
||||
>
|
||||
<ExampleGroup title="进度看板">
|
||||
<ExampleGroup title="现状看板" subtitle="2026-07-20 全仓扫描口径;数字为业务文件/出现次数实测值。">
|
||||
<CompareTable
|
||||
head={['组件', '状态', '现状', '基准(收敛目标)与剩余工作']}
|
||||
rows={[
|
||||
@@ -57,19 +57,19 @@ export function ProgressOverview() {
|
||||
[
|
||||
'Button 按钮',
|
||||
'🟨 进行中',
|
||||
'5 路并行(旧 API 已自动映射)',
|
||||
'color×variant 双轴 v1 已落地;剩:设计师验收 → 逐批迁移 → 清退 btn 系全局类',
|
||||
'原生 <button> 485 处 / Button 组件 264 处 / 全局 btn 类',
|
||||
'双轴 v1 已落地,但新 color 属性业务仅 1 处使用;485 处野生 button 未收编',
|
||||
],
|
||||
[
|
||||
'Modal 弹窗',
|
||||
'🟨 进行中',
|
||||
'5 个并行体系 · 约 64 个业务文件',
|
||||
'5 个并行壳体系 · 63 个业务文件',
|
||||
'标准待定 · C 套壳为基准候选(见 Modal 迁移页「待设计师定夺」)',
|
||||
],
|
||||
[
|
||||
'二次确认弹窗',
|
||||
'🟨 进行中',
|
||||
'2 套体系(真确认已全部迁 C 套)',
|
||||
'2 套体系;业务仍有 13 处旧 selectClasses',
|
||||
'B 套壳与按钮已对齐 C 套;剩:死 UI 清理、表单弹窗归 Modal 期',
|
||||
],
|
||||
['点赞 / 点踩', '✅ 完成', '1(已统一)', 'MessageFeedbackButtons 全 6 类回答界面共用'],
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ComponentPage, ExampleGroup, CompareTable } from '../components/kit';
|
||||
export function TypographyProgress() {
|
||||
return (
|
||||
<ComponentPage
|
||||
title="字体 · 迁移"
|
||||
eng="Typography Progress"
|
||||
title="字体 · 现状"
|
||||
eng="Typography Inventory"
|
||||
description={
|
||||
<>
|
||||
九档 semantic token + 系统字体栈已落地(2026-07-14)。旧 Tailwind
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Outlined } from "bisheng-icons";
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { KnowledgeSpace, SpaceRole, SPACE_CHILDREN_STATUS_NUMS_EXCLUDE_FAILED } from "~/api/knowledge";
|
||||
import {
|
||||
@@ -215,8 +216,14 @@ export default function KnowledgeSpaceItem({
|
||||
onClick={() => onSelect(space)}
|
||||
onContextMenu={handleRowContextMenu}
|
||||
>
|
||||
{/* Right-click menu: an invisible cursor-anchored trigger drives the same items as the "..." menu. */}
|
||||
{!hideMoreMenu && (
|
||||
{/* Right-click menu: an invisible cursor-anchored trigger drives the same
|
||||
items as the "..." menu. Portaled to <body> so the trigger's
|
||||
`position: fixed` anchors to the viewport — the sidebar scroll container
|
||||
has `container-type: inline-size`, which would otherwise become the
|
||||
trigger's containing block and offset the popup from the cursor (visible
|
||||
in the WeCom WebView). React events still bubble through the component
|
||||
tree, so the stopPropagation calls below still guard the row's onClick. */}
|
||||
{!hideMoreMenu && createPortal(
|
||||
<DropdownMenu open={contextMenuOpen} onOpenChange={setContextMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -231,7 +238,8 @@ export default function KnowledgeSpaceItem({
|
||||
<SidebarListMoreMenuContent onClick={(e) => e.stopPropagation()}>
|
||||
{moreMenuItems}
|
||||
</SidebarListMoreMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu>,
|
||||
document.body,
|
||||
)}
|
||||
<div className="flex items-center flex-1">
|
||||
{/* Expand/collapse chevron — only shown when treeEnabled.
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
--fill-4: var(--arco-gray-4); /* filled active */
|
||||
--border-base: var(--arco-gray-3); /* regular border ("border" in the spec) */
|
||||
--border-deep: var(--arco-gray-4); /* emphasized divider / hover border */
|
||||
--bg-page: 255 255 255; /* page surface: white, darkens in .dark */
|
||||
|
||||
/* Semantic — functional (never themed) */
|
||||
--success: 0 180 42; /* #00B42A (Arco green-6) */
|
||||
@@ -412,6 +413,24 @@ html {
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--switch-unchecked: 0 0% 40%;
|
||||
|
||||
/* Arco gray ramp — dark theme (official @arco-design/color gray.dark).
|
||||
* Lightness inverts (gray-1 darkest, gray-10 lightest), so the semantic
|
||||
* text / fill / border tokens defined in :root as var(--arco-gray-N)
|
||||
* auto-flip under .dark (custom props resolve lazily at use time).
|
||||
* Neutral + semantic only this round; brand and functional dark ramps
|
||||
* are still pending (they keep light values in dark — known debt). */
|
||||
--arco-gray-1: 23 23 26; /* #17171A */
|
||||
--arco-gray-2: 46 46 48; /* #2E2E30 */
|
||||
--arco-gray-3: 72 72 73; /* #484849 */
|
||||
--arco-gray-4: 95 95 96; /* #5F5F60 */
|
||||
--arco-gray-5: 120 120 122; /* #78787A */
|
||||
--arco-gray-6: 146 146 147; /* #929293 */
|
||||
--arco-gray-7: 171 171 172; /* #ABABAC */
|
||||
--arco-gray-8: 197 197 197; /* #C5C5C5 */
|
||||
--arco-gray-9: 223 223 223; /* #DFDFDF */
|
||||
--arco-gray-10: 246 246 246; /* #F6F6F6 */
|
||||
--bg-page: 18 18 18; /* #121212 — page surface, dark */
|
||||
}
|
||||
|
||||
.gizmo {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Docs-site stub for `filenamify` (rspress.config resolve.alias).
|
||||
*
|
||||
* The real package imports `node:path`, which rspack cannot bundle for the
|
||||
* browser ("unhandled scheme" — resolve.fallback doesn't cover node:-prefixed
|
||||
* ids). It only reaches the docs bundle through the ~/hooks barrel
|
||||
* (Conversations/usePresets), a path no demo executes, so an identity shim is
|
||||
* enough to keep the bundle compiling.
|
||||
*/
|
||||
export default function filenamify(input: string): string {
|
||||
return input;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Docs-site-only overrides on top of the app's style.css (rspress.config
|
||||
* loads this via source.preEntry; !important wins regardless of order).
|
||||
*
|
||||
* The app is an app-shell layout: `html, body, #root { height: 100% }` and
|
||||
* inner containers scroll. rspress needs normal document flow — with the app
|
||||
* rule active, #root is pinned to viewport height, the whole page scrolls the
|
||||
* window past it, and the sticky `.rspress-nav` (top: 0, scoped to #root)
|
||||
* scrolls away with it. Restore document flow for the docs site.
|
||||
*/
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Page background = the SAME token the chrome uses (--rp-c-bg is mapped to
|
||||
--bg-page). Without this, the app's own `.dark body` rule (a hardcoded
|
||||
rgba(33,33,33) in style.css) leaks into the docs site and the content
|
||||
column shows #212121 against the #121212 nav/sidebar. */
|
||||
html,
|
||||
body {
|
||||
background-color: rgb(var(--bg-page)) !important;
|
||||
}
|
||||
|
||||
/* Chrome: sidebar sits on gray-1 (auto-flips: #F7F8FA / #17171A) to separate
|
||||
from the content column; the nav shares the page background and separates
|
||||
via a downward shadow instead. Shadow color is the ramp's INK end + alpha —
|
||||
the dark end is gray-10 in light mode but flips to gray-1 on the dark
|
||||
ramp, so each theme picks its own token (still 100% token-driven). */
|
||||
.rspress-sidebar {
|
||||
background-color: rgb(var(--arco-gray-1)) !important;
|
||||
}
|
||||
.rspress-nav {
|
||||
background-color: rgb(var(--bg-page)) !important;
|
||||
border-bottom: 0.5px solid rgb(var(--border-base)) !important;
|
||||
box-shadow: 0 2px 8px rgb(var(--arco-gray-10) / 0.06);
|
||||
}
|
||||
/* Ink end of the dark ramp is gray-1 (the ramp flips); the resulting shadow
|
||||
is intentionally near-invisible on the #121212 bg — kept for consistency. */
|
||||
.dark .rspress-nav {
|
||||
box-shadow: 0 2px 8px rgb(var(--arco-gray-1) / 0.6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs content typography = the BiSheng type spec, applied to itself.
|
||||
*
|
||||
* This spec site must render by its own rules, so the doc body pulls the SAME
|
||||
* semantic type tokens (defined in src/style.css :root, driven by
|
||||
* design-token.cjs) the app ships — instead of rspress's theme defaults
|
||||
* (16px body, #213547 text). Result: the page demonstrates the 14px
|
||||
* `text-body` baseline, 主文字色 #1D2129 (--text-1), and the medium-weight
|
||||
* (500) heading ramp it documents. `important` because rspress's own
|
||||
* `.rspress-doc h*` rules share the same specificity.
|
||||
*/
|
||||
.rspress-doc {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||||
color: rgb(var(--text-1)) !important;
|
||||
font-size: var(--text-body) !important;
|
||||
line-height: var(--leading-body) !important;
|
||||
}
|
||||
|
||||
.rspress-doc p,
|
||||
.rspress-doc li,
|
||||
.rspress-doc td,
|
||||
.rspress-doc th {
|
||||
font-size: var(--text-body) !important;
|
||||
line-height: var(--leading-body) !important;
|
||||
/* Base text color only (NOT !important): tables set per-cell muted colors
|
||||
(text-2/3/4) via inline style — those must win. This rule still provides
|
||||
the --text-1 default and beats rspress's non-important theme color. */
|
||||
color: rgb(var(--text-1));
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Blockquote = supplementary note: body size, muted (secondary) text so it
|
||||
reads as a softer aside beside the main copy. */
|
||||
.rspress-doc blockquote,
|
||||
.rspress-doc blockquote p {
|
||||
font-size: var(--text-body) !important;
|
||||
line-height: var(--leading-body) !important;
|
||||
color: rgb(var(--text-2)) !important;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Heading ramp — spec sizes + medium (500) weight (600/700 are banned). */
|
||||
.rspress-doc h1 {
|
||||
font-size: var(--text-h1) !important;
|
||||
line-height: var(--leading-h1) !important;
|
||||
font-weight: 500 !important;
|
||||
color: rgb(var(--text-1)) !important;
|
||||
}
|
||||
.rspress-doc h2 {
|
||||
font-size: var(--text-h2) !important;
|
||||
line-height: var(--leading-h2) !important;
|
||||
font-weight: 500 !important;
|
||||
color: rgb(var(--text-1)) !important;
|
||||
}
|
||||
.rspress-doc h3 {
|
||||
font-size: var(--text-h3) !important;
|
||||
line-height: var(--leading-h3) !important;
|
||||
font-weight: 500 !important;
|
||||
color: rgb(var(--text-1)) !important;
|
||||
}
|
||||
.rspress-doc h4,
|
||||
.rspress-doc h5,
|
||||
.rspress-doc h6 {
|
||||
font-size: var(--text-h4) !important;
|
||||
line-height: var(--leading-h4) !important;
|
||||
font-weight: 500 !important;
|
||||
color: rgb(var(--text-1)) !important;
|
||||
}
|
||||
|
||||
/* IDs / code / logs use the mono stack (font-family-mono). */
|
||||
.rspress-doc code,
|
||||
.rspress-doc pre,
|
||||
.rspress-doc kbd {
|
||||
font-family:
|
||||
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Spec tables span the full content column. rspress ships the doc table as
|
||||
display:block with a max-content cap, which shrinks these narrow token
|
||||
tables to their content width; force them back to the column width. */
|
||||
.rspress-doc table {
|
||||
display: table !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* No zebra striping: rspress paints even rows with --rp-c-bg-soft. Invisible
|
||||
on white (#F7F8FA) but obvious on the dark ramp (#17171A vs #121212 page).
|
||||
Spec tables use uniform row backgrounds in BOTH themes. */
|
||||
.rspress-doc table tr,
|
||||
.rspress-doc table tr:nth-child(2n) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* ONE border color for every table rule line. rspress's MDX component map
|
||||
re-renders th/td with a Tailwind `border` class whose default color comes
|
||||
from a different palette (#d1d1d1 in light) — mixing with our token'd
|
||||
inline borders inside the same table. Force everything to --border-base. */
|
||||
.rspress-doc table,
|
||||
.rspress-doc tr,
|
||||
.rspress-doc th,
|
||||
.rspress-doc td {
|
||||
border-color: rgb(var(--border-base)) !important;
|
||||
/* rspress puts a color transition on `tr` (th/td have none), so the row
|
||||
border lags ~0.2s behind on theme switch and reads as a flicker on the
|
||||
header rule line. Flip in lockstep instead. */
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Palette strips on the color spec page — same look & interaction as the
|
||||
gallery PaletteRow (src/pages/_gallery/sections/ColorSection.tsx): seamless
|
||||
flat swatches, hover lifts (scale + shadow), role label fades in on hover.
|
||||
Own classes (not Tailwind JIT utilities) so the docs build never depends on
|
||||
which app files the content scan happened to pick up. */
|
||||
.bs-swatch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
height: 144px;
|
||||
padding: 14px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
cursor: default;
|
||||
transition:
|
||||
transform 0.2s ease-out,
|
||||
box-shadow 0.2s ease-out;
|
||||
}
|
||||
.bs-swatch:hover {
|
||||
z-index: 10;
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.bs-swatch-usage {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.bs-swatch:hover .bs-swatch-usage {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Dual-theme hex label: only the CURRENT theme's value is shown; flips with
|
||||
the .dark class instantly (pure CSS, no re-render). */
|
||||
.bs-hex-dark {
|
||||
display: none;
|
||||
}
|
||||
.dark .bs-hex-light {
|
||||
display: none;
|
||||
}
|
||||
.dark .bs-hex-dark {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site chrome (top nav / left sidebar / right TOC) — drive rspress's own
|
||||
* theme tokens (--rp-*) from the BiSheng design tokens instead of rspress
|
||||
* defaults (#213547 text, #0095ff brand, Inter, #fff bg).
|
||||
*
|
||||
* `!important` because rspress redeclares --rp-* under its dark theme; our
|
||||
* source tokens (--text-*, --brand-*, --arco-gray-*, --background) ALREADY
|
||||
* adapt light/dark (and blue⇄green brand), so one value wins in every theme
|
||||
* and the chrome follows the same system the app + docs body use.
|
||||
*/
|
||||
:root {
|
||||
/* Side rails narrowed (rspress defaults: sidebar 320, aside/TOC 268) to
|
||||
hand the freed width to the center content column. */
|
||||
--rp-sidebar-width: 256px !important;
|
||||
--rp-aside-width: 232px !important;
|
||||
|
||||
/* Neutral text */
|
||||
--rp-c-text-1: rgb(var(--text-1)) !important;
|
||||
--rp-c-text-2: rgb(var(--text-2)) !important;
|
||||
--rp-c-text-3: rgb(var(--text-3)) !important;
|
||||
--rp-c-text-4: rgb(var(--text-4)) !important;
|
||||
|
||||
/* Surfaces (page + nav/sidebar share --rp-c-bg; separated by dividers) */
|
||||
--rp-c-bg: rgb(var(--bg-page)) !important;
|
||||
--rp-c-bg-alt: rgb(var(--arco-gray-1)) !important;
|
||||
--rp-c-bg-soft: rgb(var(--arco-gray-1)) !important;
|
||||
--rp-c-bg-mute: rgb(var(--arco-gray-2)) !important;
|
||||
|
||||
/* Dividers / borders — ONE color for every rule line (spec: tables must
|
||||
not mix border grays; gray-2 vs gray-3 diverge visibly on the dark ramp) */
|
||||
--rp-c-divider: rgb(var(--border-base)) !important;
|
||||
--rp-c-divider-light: rgb(var(--border-base)) !important;
|
||||
|
||||
/* Brand — active sidebar/TOC item, links: follows blue⇄green theme */
|
||||
--rp-c-brand: rgb(var(--brand-500)) !important;
|
||||
--rp-c-brand-dark: rgb(var(--brand-600)) !important;
|
||||
--rp-c-brand-darker: rgb(var(--brand-700)) !important;
|
||||
--rp-c-brand-light: rgb(var(--brand-400)) !important;
|
||||
--rp-c-brand-lighter: rgb(var(--brand-300)) !important;
|
||||
--rp-c-brand-tint: rgb(var(--brand-500) / 0.12) !important;
|
||||
|
||||
/* Type stacks = BiSheng font-family tokens (system + CJK) */
|
||||
--rp-font-family-base:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif !important;
|
||||
--rp-font-family-mono:
|
||||
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
|
||||
monospace !important;
|
||||
}
|
||||
|
||||
/* Chrome font sizes → BiSheng type scale (rspress hardcodes 16px). Containers
|
||||
+ the stable rspress link classes; sizes flow to inner text that inherits. */
|
||||
.rspress-nav,
|
||||
.rspress-nav a,
|
||||
.rspress-nav span {
|
||||
font-size: var(--text-body) !important;
|
||||
}
|
||||
.rspress-sidebar {
|
||||
font-size: var(--text-body) !important;
|
||||
}
|
||||
.rspress-sidebar-section-header {
|
||||
font-size: var(--text-body-sm) !important;
|
||||
}
|
||||
.rspress-aside,
|
||||
.aside-link-text {
|
||||
font-size: var(--text-body-sm) !important;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Tailwind config for the DOCS SITE only (rspress) — proves design-token.js is
|
||||
* consumable from a Tailwind `theme` field without touching the live app config.
|
||||
*
|
||||
* It imports the app's tailwind.config.cjs READ-ONLY and layers the SSOT's
|
||||
* `tailwindTheme` on top, so:
|
||||
* • every existing app utility (blue-*, text-body, bg-fill-1 …) still works in demos;
|
||||
* • the new SSOT-driven semantic utilities (text-text-title, bg-fill-hover …) exist too.
|
||||
*
|
||||
* The app's tailwind.config.cjs is unchanged; when the team is ready, the same two
|
||||
* lines (`require('./src/design-token')` + spread) can move into it verbatim.
|
||||
*/
|
||||
const app = require('./tailwind.config.cjs');
|
||||
const { tailwindTheme, TEXT, FILL, BORDER, BG } = require('./src/design-token.cjs');
|
||||
|
||||
// Safelist the SSOT-driven semantic utilities so they always exist in the docs
|
||||
// build (Tailwind JIT would otherwise only emit classes it literally finds in
|
||||
// scanned source). This both guarantees the utilities are available to spec /
|
||||
// demo authors and proves this config — not the app's — is the one running.
|
||||
const safelist = [
|
||||
...TEXT.map((t) => `text-text-${t.name}`),
|
||||
...FILL.map((f) => `bg-fill-${f.name}`),
|
||||
...BORDER.map((b) => `border-border-${b.name}`),
|
||||
...BG.map((b) => `bg-bg-${b.name}`),
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
...app,
|
||||
safelist,
|
||||
theme: {
|
||||
...app.theme,
|
||||
extend: {
|
||||
...app.theme.extend,
|
||||
colors: { ...app.theme.extend.colors, ...tailwindTheme.colors },
|
||||
fontSize: { ...app.theme.extend.fontSize, ...tailwindTheme.fontSize },
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user