Compare commits

...
5 Commits
Author SHA1 Message Date
DiheChen d72b4203cb feat: browse stored media page by page, sort and search it, and delete several songs at once
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-11 00:23:50 +08:00
DiheChen 0f6ecaf6da feat: organize settings into sections that apply changes immediately 2026-11-11 00:22:33 +08:00
DiheChen 8cacfb68ff feat: organize the admin page into sections with room search, paging and source status cards 2026-11-11 00:21:31 +08:00
DiheChen c7a5c44b1c feat: add sectioned page layout, switches, segmented controls, paging and neutral avatars 2026-11-11 00:20:26 +08:00
DiheChen fb751ca57f feat: page, sort and search stored media and delete several at once in the admin API 2026-11-11 00:19:07 +08:00
113 changed files with 5441 additions and 1342 deletions

No files matched your search

+13 -4
View File
@@ -1,10 +1,19 @@
-- name: ListStoredMedia :many
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL
ORDER BY prepared_at DESC, track_id;
-- 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\'
ORDER BY CASE WHEN @sort::text = 'stored' THEN prepared_at END DESC,
CASE WHEN @sort::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN @sort::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT @page_size::bigint OFFSET @skip::bigint;
-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || @pattern::text || '%' ESCAPE '\';
-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key = @media_key::text;
-- name: DeleteStoredMedia :exec
+1 -3
View File
@@ -160,7 +160,7 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminSetGithubCredential, srv.adminSetGithubCredential)
srv.handle(mux, protocol.RouteAdminDeleteGithubCredential, srv.adminDeleteGithubCredential)
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminRemoveMedia, srv.adminRemoveMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
@@ -326,9 +326,7 @@ var knownErrors = []struct {
{errUnreadableBody, protocol.ErrorCodeInvalidArgument},
{port.ErrRoomNotFound, protocol.ErrorCodeNotFound},
{port.ErrSlugTaken, protocol.ErrorCodeConflict},
{media.ErrNotFound, protocol.ErrorCodeNotFound},
{media.ErrNotInRoom, protocol.ErrorCodeNotFound},
{media.ErrInUse, protocol.ErrorCodeConflict},
{identity.ErrInvalidMemberToken, protocol.ErrorCodeUnauthorized},
{identity.ErrWrongPassword, protocol.ErrorCodeUnauthorized},
{admin.ErrInvalidToken, protocol.ErrorCodeUnauthorized},
+48 -14
View File
@@ -3,10 +3,9 @@ package api
import (
"context"
"net/http"
"regexp"
"time"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
)
@@ -43,33 +42,68 @@ func (s *server) redirectTarget(ctx context.Context, r *http.Request, t track.Tr
return s.Playback.URL(ctx, t, time.Now())
}
var mediaKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
var mediaSorts = map[protocol.MediaSort]port.MediaSort{
protocol.MediaSortStored: port.MediaByStored,
protocol.MediaSortIdle: port.MediaByIdle,
protocol.MediaSortSize: port.MediaBySize,
}
func (s *server) adminListMedia(ctx context.Context, _ call) (any, error) {
list, err := s.Media.List(ctx)
func (s *server) adminListMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminListMediaRequest(c.body)
if err != nil {
return nil, err
}
items := make([]protocol.StoredMedia, len(list))
for i, m := range list {
q := port.MediaQuery{Page: 1, Sort: port.MediaByStored}
if req.Page != nil {
q.Page = int(*req.Page)
}
if req.Sort != nil {
q.Sort = mediaSorts[*req.Sort]
}
if req.Q != nil {
q.Title = *req.Q
}
page, err := s.Media.List(ctx, q)
if err != nil {
return nil, err
}
items := make([]protocol.StoredMedia, len(page.Items))
for i, m := range page.Items {
link := m.Link
// 后台把 link 当作链接打开,B 站的歌给出视频页面。
if m.Source == track.Bilibili {
link = track.BilibiliPageURL(m.Link)
}
items[i] = protocol.StoredMedia{Key: m.Key, Source: protocol.Source(m.Source), Link: link, Title: m.Title, Size: m.Size, StoredAtMs: m.StoredAt.UnixMilli()}
items[i] = protocol.StoredMedia{
Key: m.Key,
Source: protocol.Source(m.Source),
Link: link,
Title: m.Title,
Artist: m.Artist,
CoverURL: s.coverURL(m.CoverURL, m.CoverKey),
Size: m.Size,
StoredAtMs: m.StoredAt.UnixMilli(),
}
if m.LastPlayedAt != nil {
played := m.LastPlayedAt.UnixMilli()
items[i].LastPlayedAtMs = &played
}
}
return protocol.AdminListMediaResponse{Items: items}, nil
return protocol.AdminListMediaResponse{Items: items, Total: int64(page.Total), TotalBytes: page.TotalBytes, Page: int64(q.Page), PageSize: port.MediaPageSize}, nil
}
func (s *server) adminDeleteMedia(ctx context.Context, c call) (any, error) {
key := c.r.PathValue("key")
if !mediaKeyPattern.MatchString(key) {
return nil, media.ErrNotFound
func (s *server) adminRemoveMedia(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminRemoveMediaRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Media.Delete(ctx, key)
keys := make([]string, len(req.Media))
for i, m := range req.Media {
keys[i] = m.Key
}
deleted, inUse, err := s.Media.DeleteMany(ctx, keys)
if err != nil {
return nil, err
}
return protocol.AdminRemoveMediaResponse{Deleted: deleted, InUse: inUse}, nil
}
+44 -44
View File
@@ -24,63 +24,63 @@ import (
"github.com/dihemoe/alisten/protocol"
)
type storedMedia struct {
items []port.StoredMedia
used bool
}
func (m *storedMedia) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
return m.items, nil
}
func (m *storedMedia) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
for _, item := range m.items {
if item.Key == key {
return item, true, nil
}
}
return port.StoredMedia{}, false, nil
}
func (m *storedMedia) DeleteStoredMedia(context.Context, string) error {
m.items = nil
return nil
}
func (m *storedMedia) TrackInUse(context.Context, track.Track) (bool, error) {
return m.used, nil
}
func TestAdminMedia(t *testing.T) {
key := media.Key("https://example.com/a.mp3")
other := media.Key("https://example.com/b.mp3")
video := media.Key("BV1xx411c7mD/2")
played := time.UnixMilli(1760000000456)
stored := &storedMedia{items: []port.StoredMedia{
{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Size: 12290, StoredAt: time.UnixMilli(1760000000123), LastPlayedAt: &played},
{Key: media.Key("https://example.com/b.mp3"), Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, StoredAt: time.UnixMilli(1760000000789)},
{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
}}
library := porttest.NewMediaLibrary(
port.StoredMedia{Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Artist: "周杰伦", Size: 12290, CoverKey: track.ObjectKey("link/" + key + "/cover.jpg"), StoredAt: time.UnixMilli(1760000000123)},
port.StoredMedia{Key: other, Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, CoverURL: "https://example.com/b.jpg", StoredAt: time.UnixMilli(1760000000789), LastPlayedAt: &played},
port.StoredMedia{Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Artist: "某个 UP 主", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
)
store := porttest.New()
s := services(store, store, "admin-secret")
s.Media = media.NewLibrary(stored, stored, porttest.NewMediaStore())
s.StorageURL = "https://alisten.example/storage/alisten"
s.Media = media.NewLibrary(library, inRooms{{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}}, porttest.NewMediaStore())
server, _ := start(t, s)
admin := []string{"Authorization", "Bearer admin-secret"}
want := response{status: http.StatusOK, body: `{"items":[` +
`{"key":"` + key + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"},` +
`{"key":"` + media.Key("https://example.com/b.mp3") + `","lastPlayedAtMs":null,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"}]}`}
if got := send(t, server, http.MethodGet, "/api/admin/media", "", admin...); got != want {
t.Errorf("列出已存储的媒体 = %#v\n期望 %#v", got, want)
`{"artist":"某个 UP 主","coverUrl":"","key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"},` +
`{"artist":"","coverUrl":"https://example.com/b.jpg","key":"` + other + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
`{"artist":"周杰伦","coverUrl":"https://alisten.example/storage/alisten/link/` + key + `/cover.jpg","key":"` + key + `","lastPlayedAtMs":null,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"}],` +
`"page":1,"pageSize":50,"total":3,"totalBytes":12293}`}
if got := send(t, server, http.MethodPost, "/api/admin/media/queries", "{}", admin...); got != want {
t.Errorf("按默认参数列出已存储的媒体 = %#v\n期望 %#v", got, want)
}
for _, tt := range []struct {
body string
page int64
want []string
total, size int64
}{
{`{"sort": "stored"}`, 1, []string{video, other, key}, 3, 12293},
{`{"sort": "idle"}`, 1, []string{video, key, other}, 3, 12293},
{`{"sort": "size", "q": "晴天"}`, 1, []string{key, video}, 2, 12292},
{`{"page": 2}`, 2, []string{}, 3, 12293},
} {
got := send(t, server, http.MethodPost, "/api/admin/media/queries", tt.body, admin...)
var page protocol.AdminListMediaResponse
if err := json.Unmarshal([]byte(got.body), &page); err != nil {
t.Fatalf("%s = %#v", tt.body, got)
}
keys := make([]string, len(page.Items))
for i, m := range page.Items {
keys[i] = m.Key
}
if got.status != http.StatusOK || !slices.Equal(keys, tt.want) || page.Page != tt.page || page.Total != tt.total || page.TotalBytes != tt.size {
t.Errorf("%s = %#v,期望第 %d 页 %q,共 %d 首 %d 字节", tt.body, got, tt.page, tt.want, tt.total, tt.size)
}
}
stored.used = true
if got, want := send(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...), failure(protocol.ErrorCodeConflict, "这首歌还在房间的播放列表中,不能删除"); got != want {
t.Errorf("删除仍被房间引用的媒体 = %#v,期望 %#v", got, want)
if got := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+key+`"}, {"key": "not-a-key"}]}`, admin...); got.status != http.StatusBadRequest {
t.Errorf("批量删除里有格式不对的 key = %#v,期望 400、一首也不删", got)
}
if got, want := send(t, server, http.MethodPost, "/api/admin/media/removals", `{"media": [{"key": "`+video+`"}, {"key": "`+key+`"}, {"key": "`+media.Key("https://example.com/missing.mp3")+`"}]}`, admin...),
(response{status: http.StatusOK, body: `{"deleted":["` + key + `"],"inUse":["` + video + `"]}`}); got != want {
t.Errorf("批量删除 = %#v,期望 %#v", got, want)
}
stored.used = false
resp, header := exchange(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...)
checkNoContent(t, "删除媒体", resp, header)
}
type playURL map[string]string
@@ -10,6 +10,23 @@ import (
"time"
)
const countStoredMedia = `-- name: CountStoredMedia :one
SELECT count(*) AS total, coalesce(sum(media_size), 0)::bigint AS total_bytes FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
`
type CountStoredMediaRow struct {
Total int64
TotalBytes int64
}
func (q *Queries) CountStoredMedia(ctx context.Context, pattern string) (CountStoredMediaRow, error) {
row := q.db.QueryRow(ctx, countStoredMedia, pattern)
var i CountStoredMediaRow
err := row.Scan(&i.Total, &i.TotalBytes)
return i, err
}
const deleteStoredMedia = `-- name: DeleteStoredMedia :exec
DELETE FROM tracks WHERE media_key = $1::text
`
@@ -20,24 +37,43 @@ func (q *Queries) DeleteStoredMedia(ctx context.Context, mediaKey string) error
}
const listStoredMedia = `-- name: ListStoredMedia :many
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL
ORDER BY prepared_at DESC, track_id
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key IS NOT NULL AND title ILIKE '%' || $1::text || '%' ESCAPE '\'
ORDER BY CASE WHEN $2::text = 'stored' THEN prepared_at END DESC,
CASE WHEN $2::text = 'idle' THEN last_played_at END ASC NULLS FIRST,
CASE WHEN $2::text = 'size' THEN media_size END DESC,
track_id COLLATE "C"
LIMIT $4::bigint OFFSET $3::bigint
`
type ListStoredMediaParams struct {
Pattern string
Sort string
Skip int64
PageSize int64
}
type ListStoredMediaRow struct {
MediaKey string
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
}
func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia)
// 曲目 ID 按字节比较,排序不随数据库的 locale 变化。
func (q *Queries) ListStoredMedia(ctx context.Context, arg ListStoredMediaParams) ([]ListStoredMediaRow, error) {
rows, err := q.db.Query(ctx, listStoredMedia,
arg.Pattern,
arg.Sort,
arg.Skip,
arg.PageSize,
)
if err != nil {
return nil, err
}
@@ -50,7 +86,9 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
@@ -66,7 +104,7 @@ func (q *Queries) ListStoredMedia(ctx context.Context) ([]ListStoredMediaRow, er
}
const storedMedia = `-- name: StoredMedia :one
SELECT media_key::text AS media_key, source, track_id, title, media_size::bigint AS media_size, cover_key, prepared_at, last_played_at FROM tracks
SELECT media_key::text AS media_key, source, track_id, title, artist, media_size::bigint AS media_size, cover_url, cover_key, prepared_at, last_played_at FROM tracks
WHERE media_key = $1::text
`
@@ -75,7 +113,9 @@ type StoredMediaRow struct {
Source string
TrackID string
Title string
Artist *string
MediaSize int64
CoverURL *string
CoverKey *string
PreparedAt time.Time
LastPlayedAt *time.Time
@@ -89,7 +129,9 @@ func (q *Queries) StoredMedia(ctx context.Context, mediaKey string) (StoredMedia
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.MediaSize,
&i.CoverURL,
&i.CoverKey,
&i.PreparedAt,
&i.LastPlayedAt,
+40 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -15,16 +16,45 @@ import (
var _ port.MediaLibrary = (*Store)(nil)
func (s *Store) ListStoredMedia(ctx context.Context) ([]port.StoredMedia, error) {
rows, err := s.q.ListStoredMedia(ctx)
var mediaSorts = map[port.MediaSort]string{port.MediaByStored: "stored", port.MediaByIdle: "idle", port.MediaBySize: "size"}
// 搜索的文字按字面匹配:LIKE 的通配符和转义符都要转义。
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
func (s *Store) ListStoredMedia(ctx context.Context, q port.MediaQuery) (port.MediaPage, error) {
pattern := likeEscaper.Replace(q.Title)
var page port.MediaPage
// 总数和这一页在同一个快照里读,两者才对得上。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
qtx := s.q.WithTx(tx)
count, err := qtx.CountStoredMedia(ctx, pattern)
if err != nil {
return err
}
page.Total, page.TotalBytes = int(count.Total), count.TotalBytes
// 页码来自客户端,可能大到算偏移量时溢出;超出最后一页时也不必再查。
if int64(q.Page-1) > count.Total/port.MediaPageSize {
return nil
}
rows, err := qtx.ListStoredMedia(ctx, query.ListStoredMediaParams{
Pattern: pattern,
Sort: mediaSorts[q.Sort],
Skip: int64(q.Page-1) * port.MediaPageSize,
PageSize: port.MediaPageSize,
})
if err != nil {
return err
}
page.Items = make([]port.StoredMedia, len(rows))
for i, r := range rows {
page.Items[i] = storedMedia(query.StoredMediaRow(r))
}
return nil
})
if err != nil {
return nil, fmt.Errorf("读取已存储的媒体:%w", err)
return port.MediaPage{}, fmt.Errorf("读取已存储的媒体:%w", err)
}
media := make([]port.StoredMedia, len(rows))
for i, r := range rows {
media[i] = storedMedia(query.StoredMediaRow(r))
}
return media, nil
return page, nil
}
func (s *Store) StoredMedia(ctx context.Context, key string) (port.StoredMedia, bool, error) {
@@ -51,7 +81,9 @@ func storedMedia(r query.StoredMediaRow) port.StoredMedia {
Source: track.Source(r.Source),
Link: r.TrackID,
Title: r.Title,
Artist: orEmpty(r.Artist),
Size: r.MediaSize,
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
StoredAt: r.PreparedAt.UTC(),
LastPlayedAt: utc(r.LastPlayedAt),
+122 -12
View File
@@ -1,7 +1,12 @@
package postgres
import (
"cmp"
"fmt"
"math"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -10,10 +15,11 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key, cover string, size int64) {
func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key string, meta track.Meta, size int64) {
t.Helper()
meta.Title, meta.PageURL = "标题 "+key, pageURL
p := port.PreparedTrack{
Meta: track.Meta{Title: "标题 " + key, CoverKey: track.ObjectKey(cover), PageURL: pageURL},
Meta: meta,
Result: track.Prepared{Duration: time.Minute, Gain: 1},
MediaKey: key,
MediaSize: size,
@@ -27,9 +33,9 @@ func TestStoredMedia(t *testing.T) {
store, _ := newStore(t)
a := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
saveStored(t, store, a, a.ID, "aaa", "link/aaa/cover.jpg", 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", "", 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", "link/ccc/cover.jpg", 300)
saveStored(t, store, a, a.ID, "aaa", track.Meta{Artist: "周杰伦", CoverKey: "link/aaa/cover.jpg"}, 100)
saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", track.Meta{CoverURL: "https://example.com/b.jpg"}, 200)
saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", track.Meta{Artist: "某个 UP 主", CoverKey: "link/ccc/cover.jpg"}, 300)
addTrack(t, store, netease("1"), track.Meta{Title: "晴天"}, track.Prepared{Duration: time.Minute, Gain: 1})
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
@@ -37,22 +43,23 @@ func TestStoredMedia(t *testing.T) {
played := room.Entry{ID: 1, Track: a, By: alice}
saveRoom(t, store, term, snap, room.RecordPlay{Entry: played, StartedAt: t0, EndedAt: t0.Add(time.Minute)})
list, err := store.ListStoredMedia(t.Context())
page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored})
if err != nil {
t.Fatal(err)
}
list := page.Items
if len(list) != 3 || list[0].Key != "ccc" || list[1].Key != "bbb" || list[2].Key != "aaa" {
t.Fatalf("已存储的媒体 = %+v,期望两首链接和一首 B 站的歌,后存入的在前", list)
}
got := list[2]
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Size != 100 || got.CoverKey != "link/aaa/cover.jpg" ||
if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Artist != "周杰伦" || got.Size != 100 || got.CoverURL != "" || got.CoverKey != "link/aaa/cover.jpg" ||
got.StoredAt.IsZero() || got.LastPlayedAt == nil || !got.LastPlayedAt.Equal(t0) {
t.Errorf("播放过的链接 = %+v", got)
}
if b := list[1]; b.Source != track.Link || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有封面、没有播放过的链接 = %+v", b)
if b := list[1]; b.Source != track.Link || b.Artist != "" || b.CoverURL != "https://example.com/b.jpg" || b.CoverKey != "" || b.LastPlayedAt != nil {
t.Errorf("没有歌手、封面不在对象存储里、没有播放过的链接 = %+v", b)
}
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.Artist != "某个 UP 主" || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
t.Errorf("B 站的歌 = %+v", c)
}
@@ -74,7 +81,110 @@ func TestStoredMedia(t *testing.T) {
t.Errorf("清除之后 %v 的准备结果 = %v, %v,期望没有", tr, ok, err)
}
}
if list, err := store.ListStoredMedia(t.Context()); err != nil || len(list) != 1 {
t.Errorf("清除之后的列表 = %+v, %v", list, err)
if page, err := store.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored}); err != nil || len(page.Items) != 1 || page.Total != 1 {
t.Errorf("清除之后的列表 = %+v, %v", page, err)
}
}
func mediaKeys(items []port.StoredMedia) []string {
keys := make([]string, len(items))
for i, m := range items {
keys[i] = m.Key
}
slices.Sort(keys)
return keys
}
// 120 首:存储时间和最近播放时间各不相同,其中 10 首没有播放过;每 3 首大小相同,要靠曲目 ID 分出先后。
// 有 3 首的标题含 50%_off;另外两首只在 % 或 _ 被当作通配符时才会匹配它。
func TestStoredMediaPages(t *testing.T) {
store, pool := newStore(t)
titles := map[int]string{5: "Summer 50%_off", 50: "50%_OFF 特价", 95: "全场50%_Off", 20: "50abc_off", 30: "50%Xoff", 40: `C:\music`}
all := make([]port.StoredMedia, 120)
var totalBytes int64
for i := range all {
// 曲目 ID 的顺序与存入的顺序不同,大小相同时是否按曲目 ID 排才看得出来。
link := fmt.Sprintf("https://example.com/%03d.mp3", i*7%120)
m := port.StoredMedia{
Key: fmt.Sprintf("key%03d", i), Source: track.Link, Link: link, Title: cmp.Or(titles[i], fmt.Sprintf("第 %d 首", i)),
Size: int64(1000 + i*11%40), StoredAt: t0.Add(time.Duration(i*13%120) * time.Minute),
}
if i%12 != 0 {
played := t0.Add(time.Duration(i*17%120) * time.Hour)
m.LastPlayedAt = &played
}
saveStored(t, store, track.Track{Source: track.Link, ID: link}, link, m.Key, track.Meta{}, m.Size)
if _, err := pool.Exec(t.Context(), "UPDATE tracks SET title = $1, prepared_at = $2, last_played_at = $3 WHERE media_key = $4", m.Title, m.StoredAt, m.LastPlayedAt, m.Key); err != nil {
t.Fatal(err)
}
all[i] = m
totalBytes += m.Size
}
list := func(q port.MediaQuery) port.MediaPage {
t.Helper()
page, err := store.ListStoredMedia(t.Context(), q)
if err != nil {
t.Fatal(err)
}
return page
}
// 每一页都报告全部的数量和大小;翻过最后一页是空的。
pages := func(sort port.MediaSort) []port.StoredMedia {
t.Helper()
var items []port.StoredMedia
for i, want := range []int{50, 50, 20, 0} {
page := list(port.MediaQuery{Page: i + 1, Sort: sort})
if len(page.Items) != want || page.Total != 120 || page.TotalBytes != totalBytes {
t.Fatalf("排序 %d 的第 %d 页有 %d 首,共 %d 首 %d 字节,期望 %d 首,共 120 首 %d 字节", sort, i+1, len(page.Items), page.Total, page.TotalBytes, want, totalBytes)
}
items = append(items, page.Items...)
}
if got, want := mediaKeys(items), mediaKeys(all); !slices.Equal(got, want) {
t.Fatalf("排序 %d 依次翻页 = %q,期望不重复也不遗漏", sort, got)
}
return items
}
inOrder := func(name string, items []port.StoredMedia, compare func(a, b port.StoredMedia) int) {
t.Helper()
for i := 1; i < len(items); i++ {
if compare(items[i-1], items[i]) >= 0 {
t.Fatalf("%s:%+v 排在了 %+v 之前", name, items[i-1], items[i])
}
}
}
byLink := func(a, b port.StoredMedia) int { return strings.Compare(a.Link, b.Link) }
inOrder("按存储时间", pages(port.MediaByStored), func(a, b port.StoredMedia) int { return b.StoredAt.Compare(a.StoredAt) })
idle := pages(port.MediaByIdle)
for i, m := range idle {
if (m.LastPlayedAt == nil) != (i < 10) {
t.Fatalf("按最近播放的第 %d 首 = %+v,期望前 10 首是没有播放过的", i+1, m)
}
}
inOrder("没有播放过的", idle[:10], byLink)
inOrder("按最近播放", idle[10:], func(a, b port.StoredMedia) int { return a.LastPlayedAt.Compare(*b.LastPlayedAt) })
inOrder("按大小", pages(port.MediaBySize), func(a, b port.StoredMedia) int { return cmp.Or(cmp.Compare(b.Size, a.Size), byLink(a, b)) })
if page := list(port.MediaQuery{Page: math.MaxInt, Sort: port.MediaBySize}); len(page.Items) != 0 || page.Total != 120 || page.TotalBytes != totalBytes {
t.Errorf("页码极大时 = %d 首,共 %d 首 %d 字节,期望没有,总数照常", len(page.Items), page.Total, page.TotalBytes)
}
for _, tt := range []struct {
title string
want []int
}{
{"50%_off", []int{5, 50, 95}},
{"OFF", []int{5, 20, 30, 50, 95}},
{`:\m`, []int{40}},
} {
var want []port.StoredMedia
var bytes int64
for _, i := range tt.want {
want = append(want, all[i])
bytes += all[i].Size
}
page := list(port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: tt.title})
if got := mediaKeys(page.Items); !slices.Equal(got, mediaKeys(want)) || page.Total != len(want) || page.TotalBytes != bytes {
t.Errorf("搜索 %q = %q,共 %d 首 %d 字节,期望 %q,共 %d 首 %d 字节", tt.title, got, page.Total, page.TotalBytes, mediaKeys(want), len(want), bytes)
}
}
}
+17 -2
View File
@@ -23,8 +23,23 @@ func NewLibrary(media port.MediaLibrary, usage port.TrackUsage, store port.Media
return &Library{media: media, usage: usage, store: store}
}
func (l *Library) List(ctx context.Context) ([]port.StoredMedia, error) {
return l.media.ListStoredMedia(ctx)
func (l *Library) List(ctx context.Context, q port.MediaQuery) (port.MediaPage, error) {
return l.media.ListStoredMedia(ctx, q)
}
// 逐个删除:正在房间里使用的放进 inUse,不存在的忽略,其他错误立即返回,已经删掉的不会恢复。
func (l *Library) DeleteMany(ctx context.Context, keys []string) (deleted, inUse []string, err error) {
for _, key := range keys {
switch err := l.Delete(ctx, key); {
case err == nil:
deleted = append(deleted, key)
case errors.Is(err, ErrInUse):
inUse = append(inUse, key)
case !errors.Is(err, ErrNotFound):
return nil, nil, err
}
}
return deleted, inUse, nil
}
// 先删除对象,再清除准备结果:中途失败时准备结果还在,后台仍能看到这首歌并重试;
+77 -45
View File
@@ -1,7 +1,6 @@
package media
import (
"context"
"errors"
"slices"
"strings"
@@ -13,59 +12,52 @@ import (
"github.com/dihemoe/alisten/internal/domain/track"
)
type library struct {
media map[string]port.StoredMedia
used map[track.Track]bool
func stored(tr track.Track) port.StoredMedia {
key := Key(tr.ID)
return port.StoredMedia{Key: key, Source: tr.Source, Link: tr.ID, Title: "晴天", Size: 3, CoverKey: track.ObjectKey(CoverObject(key, "jpg")), StoredAt: time.Now()}
}
func (l *library) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
var list []port.StoredMedia
for _, m := range l.media {
list = append(list, m)
}
return list, nil
}
func (l *library) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
m, ok := l.media[key]
return m, ok, nil
}
func (l *library) DeleteStoredMedia(_ context.Context, key string) error {
delete(l.media, key)
return nil
}
func (l *library) TrackInUse(_ context.Context, t track.Track) (bool, error) {
return l.used[t], nil
}
func newLibrary(t *testing.T) (*Library, *library, *porttest.MediaStore) {
// 每首歌的音频和封面都在对象存储中,另外还有一个不属于它们的对象。
func newLibrary(t *testing.T, used inRooms, media ...port.StoredMedia) (*Library, *porttest.MediaLibrary, *porttest.MediaStore) {
t.Helper()
key := Key(link.ID)
lib := &library{
media: map[string]port.StoredMedia{key: {Key: key, Source: track.Link, Link: link.ID, Title: "晴天", Size: 3, CoverKey: track.ObjectKey(CoverObject(key, "jpg")), StoredAt: time.Now()}},
used: map[track.Track]bool{},
}
lib := porttest.NewMediaLibrary(media...)
store := porttest.NewMediaStore()
for _, object := range []string{AudioObject(key), CoverObject(key, "jpg"), AudioObject("other")} {
objects := []string{AudioObject("other")}
for _, m := range media {
objects = append(objects, AudioObject(m.Key), string(m.CoverKey))
}
for _, object := range objects {
if err := store.Put(t.Context(), object, strings.NewReader("data"), "application/octet-stream"); err != nil {
t.Fatal(err)
}
}
return NewLibrary(lib, lib, store), lib, store
return NewLibrary(lib, used, store), lib, store
}
func storedKeys(t *testing.T, lib *porttest.MediaLibrary) []string {
t.Helper()
page, err := lib.ListStoredMedia(t.Context(), port.MediaQuery{Page: 1, Sort: port.MediaByStored})
if err != nil {
t.Fatal(err)
}
keys := make([]string, len(page.Items))
for i, m := range page.Items {
keys[i] = m.Key
}
slices.Sort(keys)
return keys
}
func TestDeleteRemovesObjectsAndResult(t *testing.T) {
l, lib, store := newLibrary(t)
l, lib, store := newLibrary(t, inRooms{}, stored(link))
if err := l.Delete(t.Context(), Key(link.ID)); err != nil {
t.Fatal(err)
}
if keys := store.Keys(); !slices.Equal(keys, []string{AudioObject("other")}) {
t.Errorf("删除之后的对象 = %q,期望音频和封面都已删除", keys)
}
if len(lib.media) != 0 {
t.Error("删除之后准备结果应已清除")
if keys := storedKeys(t, lib); len(keys) != 0 {
t.Errorf("删除之后还有准备结果 %q", keys)
}
if err := l.Delete(t.Context(), Key(link.ID)); !errors.Is(err, ErrNotFound) {
t.Errorf("再次删除 = %v,期望 ErrNotFound", err)
@@ -73,26 +65,66 @@ func TestDeleteRemovesObjectsAndResult(t *testing.T) {
}
func TestDeleteRefusesMediaInUse(t *testing.T) {
l, lib, store := newLibrary(t)
stored := lib.media[Key(link.ID)]
stored.Source = track.Bilibili
lib.media[Key(link.ID)] = stored
lib.used[track.Track{Source: track.Bilibili, ID: link.ID}] = true
video := track.Track{Source: track.Bilibili, ID: link.ID}
l, lib, store := newLibrary(t, inRooms{video: true}, stored(video))
if err := l.Delete(t.Context(), Key(link.ID)); !errors.Is(err, ErrInUse) {
t.Errorf("删除仍被房间引用的歌 = %v,期望 ErrInUse", err)
}
if len(store.Keys()) != 3 || len(lib.media) != 1 {
if len(store.Keys()) != 3 || len(storedKeys(t, lib)) != 1 {
t.Error("仍被房间引用的歌不应删除任何东西")
}
}
func TestDeleteKeepsResultWhenObjectsCannotBeDeleted(t *testing.T) {
l, lib, store := newLibrary(t)
l, lib, store := newLibrary(t, inRooms{}, stored(link))
store.Fail(errors.New("连接被拒绝"))
if err := l.Delete(t.Context(), Key(link.ID)); err == nil {
t.Fatal("对象删除失败时应返回错误")
}
if len(lib.media) != 1 {
if len(storedKeys(t, lib)) != 1 {
t.Error("对象删除失败时准备结果应保留,后台还能看到这首歌并重试")
}
}
func TestDeleteManySkipsMediaInUseAndMissing(t *testing.T) {
other := track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}
l, lib, store := newLibrary(t, inRooms{bilibili: true}, stored(link), stored(bilibili), stored(other))
deleted, inUse, err := l.DeleteMany(t.Context(), []string{Key(bilibili.ID), Key(link.ID), Key("https://example.com/missing.mp3"), Key(other.ID)})
if err != nil {
t.Fatal(err)
}
if want := []string{Key(link.ID), Key(other.ID)}; !slices.Equal(deleted, want) {
t.Errorf("删掉的 = %q,期望 %q", deleted, want)
}
if want := []string{Key(bilibili.ID)}; !slices.Equal(inUse, want) {
t.Errorf("正在使用的 = %q,期望 %q", inUse, want)
}
if keys := storedKeys(t, lib); !slices.Equal(keys, []string{Key(bilibili.ID)}) {
t.Errorf("剩下的准备结果 = %q,期望只剩正在使用的", keys)
}
if keys, want := store.Keys(), []string{AudioObject("other"), AudioObject(Key(bilibili.ID)), CoverObject(Key(bilibili.ID), "jpg")}; !slices.Equal(keys, slices.Sorted(slices.Values(want))) {
t.Errorf("剩下的对象 = %q,期望 %q", keys, want)
}
}
func TestDeleteManyDeletesAll(t *testing.T) {
l, lib, _ := newLibrary(t, inRooms{}, stored(link), stored(bilibili))
deleted, inUse, err := l.DeleteMany(t.Context(), []string{Key(link.ID), Key(bilibili.ID)})
if err != nil || !slices.Equal(deleted, []string{Key(link.ID), Key(bilibili.ID)}) || len(inUse) != 0 {
t.Errorf("DeleteMany = %q, %q, %v,期望两首都删掉", deleted, inUse, err)
}
if keys := storedKeys(t, lib); len(keys) != 0 {
t.Errorf("删除之后还有准备结果 %q", keys)
}
}
func TestDeleteManyStopsAtOtherErrors(t *testing.T) {
l, lib, store := newLibrary(t, inRooms{}, stored(link), stored(bilibili))
store.Fail(errors.New("连接被拒绝"))
if _, _, err := l.DeleteMany(t.Context(), []string{Key(link.ID), Key(bilibili.ID)}); err == nil {
t.Fatal("对象删除失败时应返回错误")
}
if len(storedKeys(t, lib)) != 2 {
t.Error("对象删除失败时准备结果应保留")
}
}
+36 -5
View File
@@ -21,18 +21,49 @@ type StoredMedia struct {
Key string
Source track.Source
// 曲目 ID:链接曲目是链接本身,B 站的歌是 BV 号。
Link string
Title string
Link string
Title string
Artist string
// 音频的字节数。
Size int64
Size int64
// 两者的含义与 track.Meta 的同名字段相同。
CoverURL string
CoverKey track.ObjectKey
StoredAt time.Time
LastPlayedAt *time.Time
}
// 每种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏。
type MediaSort uint8
const (
// 存储时间从新到旧。
MediaByStored MediaSort = iota + 1
// 最近播放从早到晚,从没播放过的排最前。
MediaByIdle
// 从大到小。
MediaBySize
)
const MediaPageSize = 50
type MediaQuery struct {
// 从 1 开始,超出最后一页时返回空的一页。
Page int
Sort MediaSort
// 标题包含它就算匹配,不区分大小写,按字面匹配;为空表示不搜索。
Title string
}
type MediaPage struct {
Items []StoredMedia
// 匹配的全部歌曲的数量和音频的字节数,不只是这一页。
Total int
TotalBytes int64
}
type MediaLibrary interface {
// 最近存入的在前。
ListStoredMedia(ctx context.Context) ([]StoredMedia, error)
ListStoredMedia(ctx context.Context, q MediaQuery) (MediaPage, error)
StoredMedia(ctx context.Context, key string) (m StoredMedia, ok bool, err error)
// 清除这首歌的准备结果。
DeleteStoredMedia(ctx context.Context, key string) error
+74
View File
@@ -1,10 +1,12 @@
package porttest
import (
"cmp"
"context"
"io"
"maps"
"slices"
"strings"
"sync"
"github.com/dihemoe/alisten/internal/app/port"
@@ -79,3 +81,75 @@ func (s *MediaStore) Fail(err error) {
defer s.mu.Unlock()
s.failure = err
}
var _ port.MediaLibrary = (*MediaLibrary)(nil)
type MediaLibrary struct {
mu sync.Mutex
media map[string]port.StoredMedia
}
func NewMediaLibrary(media ...port.StoredMedia) *MediaLibrary {
l := &MediaLibrary{media: map[string]port.StoredMedia{}}
for _, m := range media {
l.media[m.Key] = m
}
return l
}
func (l *MediaLibrary) ListStoredMedia(_ context.Context, q port.MediaQuery) (port.MediaPage, error) {
l.mu.Lock()
defer l.mu.Unlock()
var page port.MediaPage
var matched []port.StoredMedia
for _, m := range l.media {
if strings.Contains(strings.ToLower(m.Title), strings.ToLower(q.Title)) {
matched = append(matched, m)
page.TotalBytes += m.Size
}
}
slices.SortFunc(matched, func(a, b port.StoredMedia) int {
return cmp.Or(compareMedia(q.Sort, a, b), strings.Compare(a.Link, b.Link))
})
page.Total = len(matched)
if q.Page-1 > len(matched)/port.MediaPageSize {
return page, nil
}
start := (q.Page - 1) * port.MediaPageSize
page.Items = matched[start:min(start+port.MediaPageSize, len(matched))]
return page, nil
}
func compareMedia(sort port.MediaSort, a, b port.StoredMedia) int {
switch sort {
case port.MediaByStored:
return b.StoredAt.Compare(a.StoredAt)
case port.MediaByIdle:
switch {
case a.LastPlayedAt == nil && b.LastPlayedAt == nil:
return 0
case a.LastPlayedAt == nil:
return -1
case b.LastPlayedAt == nil:
return 1
}
return a.LastPlayedAt.Compare(*b.LastPlayedAt)
case port.MediaBySize:
return cmp.Compare(b.Size, a.Size)
}
return 0
}
func (l *MediaLibrary) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
l.mu.Lock()
defer l.mu.Unlock()
m, ok := l.media[key]
return m, ok, nil
}
func (l *MediaLibrary) DeleteStoredMedia(_ context.Context, key string) error {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.media, key)
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package porttest
import (
"fmt"
"math"
"slices"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
func mediaKeys(items []port.StoredMedia) []string {
keys := make([]string, len(items))
for i, m := range items {
keys[i] = m.Key
}
return keys
}
// 与 postgres 适配器的 TestStoredMediaPages 相同的规则。
func TestMediaLibraryPages(t *testing.T) {
t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
played := func(h time.Duration) *time.Time {
at := t0.Add(h * time.Hour)
return &at
}
lib := NewMediaLibrary(
port.StoredMedia{Key: "a", Source: track.Link, Link: "https://example.com/c", Title: "Summer 50%_OFF", Size: 300, StoredAt: t0.Add(time.Hour)},
port.StoredMedia{Key: "b", Source: track.Link, Link: "https://example.com/a", Title: "50abc_off", Size: 300, StoredAt: t0.Add(3 * time.Hour), LastPlayedAt: played(5)},
port.StoredMedia{Key: "c", Source: track.Link, Link: "https://example.com/b", Title: "50%Xoff", Size: 100, StoredAt: t0.Add(2 * time.Hour), LastPlayedAt: played(4)},
port.StoredMedia{Key: "d", Source: track.Bilibili, Link: "https://example.com/d", Title: "晴天", Size: 200, StoredAt: t0},
)
for _, tt := range []struct {
q port.MediaQuery
want []string
total int
bytes int64
}{
{port.MediaQuery{Page: 1, Sort: port.MediaByStored}, []string{"b", "c", "a", "d"}, 4, 900},
{port.MediaQuery{Page: 1, Sort: port.MediaByIdle}, []string{"a", "d", "c", "b"}, 4, 900},
{port.MediaQuery{Page: 1, Sort: port.MediaBySize}, []string{"b", "a", "d", "c"}, 4, 900},
{port.MediaQuery{Page: 2, Sort: port.MediaBySize}, []string{}, 4, 900},
{port.MediaQuery{Page: math.MaxInt, Sort: port.MediaBySize}, []string{}, 4, 900},
{port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: "50%_off"}, []string{"a"}, 1, 300},
{port.MediaQuery{Page: 1, Sort: port.MediaByStored, Title: "OFF"}, []string{"b", "c", "a"}, 3, 700},
} {
page, err := lib.ListStoredMedia(t.Context(), tt.q)
if got := mediaKeys(page.Items); err != nil || !slices.Equal(got, tt.want) || page.Total != tt.total || page.TotalBytes != tt.bytes {
t.Errorf("%+v = %q,共 %d 首 %d 字节,%v,期望 %q,共 %d 首 %d 字节", tt.q, got, page.Total, page.TotalBytes, err, tt.want, tt.total, tt.bytes)
}
}
var many []port.StoredMedia
for i := range port.MediaPageSize + 1 {
many = append(many, port.StoredMedia{Key: fmt.Sprintf("key%02d", i), Link: fmt.Sprintf("https://example.com/%02d", i), Size: 1, StoredAt: t0})
}
lib = NewMediaLibrary(many...)
var listed []port.StoredMedia
for p, want := range []int{port.MediaPageSize, 1, 0} {
page, err := lib.ListStoredMedia(t.Context(), port.MediaQuery{Page: p + 1, Sort: port.MediaByStored})
if err != nil || len(page.Items) != want || page.Total != len(many) {
t.Fatalf("第 %d 页 = %d 首,共 %d 首,%v,期望 %d 首,共 %d 首", p+1, len(page.Items), page.Total, err, want, len(many))
}
listed = append(listed, page.Items...)
}
if got, want := mediaKeys(listed), mediaKeys(many); !slices.Equal(got, want) {
t.Errorf("依次翻页 = %q,期望按曲目 ID 排的 %q", got, want)
}
}
+57
View File
@@ -252,3 +252,60 @@ func TestPlaylistLinkTracks(t *testing.T) {
t.Errorf("换一种大小写移出之后还有 %+v,期望没有", items.Items)
}
}
// 翻过最后一页时总数照常;批量删除时,正在房间里播放的歌不删,不存在的忽略,其余的连同音频一起删掉。
func TestAdminMediaPagesAndRemovals(t *testing.T) {
contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
svc := startService(t)
dbConfig, err := loadDatabaseConfig()
if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.NewWithConfig(t.Context(), dbConfig)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
objectsConfig, err := loadStorageConfig()
if err != nil {
t.Fatal(err)
}
objects := storage.New(objectsConfig)
const playing, idle = "https://example.com/playing.mp3", "https://example.com/idle.mp3"
for _, link := range []string{playing, idle} {
prepared := port.PreparedTrack{
Meta: track.Meta{Title: link, PageURL: link},
Result: track.Prepared{Duration: time.Hour, Gain: 1},
MediaKey: media.Key(link),
MediaSize: 3,
}
if err := postgres.New(pool).SavePreparedTrack(t.Context(), track.Track{Source: track.Link, ID: link}, prepared); err != nil {
t.Fatal(err)
}
if err := objects.Put(t.Context(), media.AudioObject(media.Key(link)), strings.NewReader("mp3"), "audio/mpeg"); err != nil {
t.Fatal(err)
}
}
_, ticket := enterRoom(t, svc, createPersistentRoom(t, svc, "大厅"))
roomRequest(t, svc, ticket, "POST /api/room/queue", `{"track": {"source": "link", "id": "`+playing+`"}}`)
admin := "\nAuthorization: Bearer admin-secret\n\n"
call := func(request, want string) {
t.Helper()
if line, status, _, body := send(t, svc.server, request); status != http.StatusOK || strings.TrimSpace(string(body)) != want {
t.Errorf("%s = %d %s,期望 %s", line, status, body, want)
}
}
call("POST /api/admin/media/queries"+admin+`{"page": 2}`, `{"items":[],"page":2,"pageSize":50,"total":2,"totalBytes":6}`)
call("POST /api/admin/media/removals"+admin+`{"media": [{"key": "`+media.Key(playing)+`"}, {"key": "`+media.Key(idle)+`"}, {"key": "`+media.Key("https://example.com/missing.mp3")+`"}]}`,
`{"deleted":["`+media.Key(idle)+`"],"inUse":["`+media.Key(playing)+`"]}`)
if line, status, _, body := send(t, svc.server, "POST /api/admin/media/queries"+admin+"{}"); status != http.StatusOK ||
!strings.Contains(string(body), `"key":"`+media.Key(playing)+`"`) || !strings.HasSuffix(strings.TrimSpace(string(body)), `"page":1,"pageSize":50,"total":1,"totalBytes":3}`) {
t.Errorf("%s = %d %s,期望只剩正在播放的歌", line, status, body)
}
for link, want := range map[string]bool{playing: true, idle: false} {
if exists, err := objects.Exists(t.Context(), media.AudioObject(media.Key(link))); err != nil || exists != want {
t.Errorf("%s 的音频还在 = %v, %v,期望 %v", link, exists, err, want)
}
}
}
+6 -2
View File
@@ -84,15 +84,19 @@ Authorization: Bearer admin-secret
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
GET /api/admin/media
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
DELETE /api/admin/media/66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}]}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+96 -9
View File
@@ -322,29 +322,116 @@ Authorization: Bearer admin-secret
{"set": false, "updatedAtMs": 0}
-- request --
GET /api/admin/media
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{}
-- response --
200
{"items": []}
{"items": [], "total": 0, "totalBytes": 0, "page": 1, "pageSize": 50}
-- request --
DELETE /api/admin/media/66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{"page": 3, "sort": "idle", "q": "50%_off"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "媒体不存在"}}
200
{"items": [], "total": 0, "totalBytes": 0, "page": 3, "pageSize": 50}
-- request --
DELETE /api/admin/media/not-a-key
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{"sort": "recent"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "媒体不存在"}}
400
{"error": {"code": "INVALID_ARGUMENT", "message": "sort:取值应为 stored、idle、size 之一"}}
-- request --
GET /api/admin/media
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{"q": "晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴晴"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "q:长度不能超过 100 个字符"}}
-- request --
POST /api/admin/media/queries
Authorization: Bearer admin-secret
{"page": 0}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "page:不能小于 1"}}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}]}
-- response --
200
{"deleted": [], "inUse": []}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "not-a-key"}]}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "media[1].key:格式不正确"}}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66CF7FBB71EA804452BE64DB6A2002116BA136DB17C97DBB7ED4A74C8D052391"}]}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "media[0].key:格式不正确"}}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": []}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "media:元素不能少于 1 个"}}
-- request --
POST /api/admin/media/removals
Authorization: Bearer admin-secret
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}, {"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}]}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "media:元素不能超过 50 个"}}
-- request --
POST /api/admin/media/queries
{}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
POST /api/admin/media/removals
{"media": [{"key": "66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391"}]}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
POST /api/admin/media/queries
Authorization: Bearer $alice
{}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
+10 -9
View File
@@ -203,10 +203,11 @@ func TestWorkerPreparesLinks(t *testing.T) {
}
admin := "Authorization: Bearer admin-secret"
if line, status, _, body := send(t, svc.server, "GET /api/admin/media\n"+admin); status != http.StatusOK || !strings.Contains(string(body), `"key":"`+key+`"`) {
t.Errorf("%s = %d %s,期望列出这首链接", line, status, body)
if line, status, _, body := send(t, svc.server, "POST /api/admin/media/queries\n"+admin+"\n\n{}"); status != http.StatusOK || !strings.Contains(string(body), `"coverUrl":"`+entryMeta["coverUrl"].(string)+`","key":"`+key+`"`) {
t.Errorf("%s = %d %s,期望列出这首链接和它的封面", line, status, body)
}
if line, status, _, body := send(t, svc.server, "DELETE /api/admin/media/"+key+"\n"+admin); status != http.StatusConflict {
remove := "POST /api/admin/media/removals\n" + admin + "\n\n" + `{"media": [{"key": "` + key + `"}]}`
if line, status, _, body := send(t, svc.server, remove); status != http.StatusOK || strings.TrimSpace(string(body)) != `{"deleted":[],"inUse":["`+key+`"]}` {
t.Errorf("%s = %d %s,期望正在播放的链接不能删除", line, status, body)
}
@@ -214,18 +215,18 @@ func TestWorkerPreparesLinks(t *testing.T) {
client.readUntil(t, func(m map[string]any) bool { return m["type"] == "playback.voteSkip" })
// 切歌之后要等落盘完成,链接曲目才不再被房间引用。
for deadline := time.Now().Add(messageWait); ; time.Sleep(100 * time.Millisecond) {
_, status, _, _ := send(t, svc.server, "DELETE /api/admin/media/"+key+"\n"+admin)
if status == http.StatusNoContent {
_, status, _, body := send(t, svc.server, remove)
if status == http.StatusOK && strings.TrimSpace(string(body)) == `{"deleted":["`+key+`"],"inUse":[]}` {
break
}
if time.Now().After(deadline) {
t.Fatalf("切歌之后删除媒体 = %d", status)
t.Fatalf("切歌之后删除媒体 = %d %s", status, body)
}
}
if status, _ := anonymousGet(t, now["mediaUrl"].(string)); status == http.StatusOK {
t.Error("删除之后仍能读到音频")
}
if _, status, _, body := send(t, svc.server, "GET /api/admin/media\n"+admin); status != http.StatusOK || strings.TrimSpace(string(body)) != `{"items":[]}` {
if _, status, _, body := send(t, svc.server, "POST /api/admin/media/queries\n"+admin+"\n\n{}"); status != http.StatusOK || strings.TrimSpace(string(body)) != `{"items":[],"page":1,"pageSize":50,"total":0,"totalBytes":0}` {
t.Errorf("删除之后的列表 = %d %s", status, body)
}
@@ -293,7 +294,7 @@ func TestWorkerPreparesBilibili(t *testing.T) {
t.Errorf("转码的输入 = %q,期望下载到的原始音频", inputs)
}
admin := "Authorization: Bearer admin-secret"
if line, status, _, body := send(t, svc.server, "GET /api/admin/media\n"+admin); status != http.StatusOK || !strings.Contains(string(body), `"key":"`+key+`"`) ||
if line, status, _, body := send(t, svc.server, "POST /api/admin/media/queries\n"+admin+"\n\n{}"); status != http.StatusOK || !strings.Contains(string(body), `"key":"`+key+`"`) ||
!strings.Contains(string(body), `"source":"bilibili"`) || !strings.Contains(string(body), `"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2"`) {
t.Errorf("%s = %d %s,期望列出这首 B 站的歌", line, status, body)
}
@@ -350,7 +351,7 @@ func TestLinkCaseVariantsAreOneTrack(t *testing.T) {
if n := downloads.downloads(); n != 1 {
t.Errorf("下载了 %d 次,期望两种写法共用一次下载", n)
}
if line, status, _, body := send(t, svc.server, "GET /api/admin/media\nAuthorization: Bearer admin-secret"); status != http.StatusOK || strings.Count(string(body), `"key"`) != 1 || !strings.Contains(string(body), `"key":"`+media.Key(link)+`"`) {
if line, status, _, body := send(t, svc.server, "POST /api/admin/media/queries\nAuthorization: Bearer admin-secret\n\n{}"); status != http.StatusOK || strings.Count(string(body), `"key"`) != 1 || !strings.Contains(string(body), `"key":"`+media.Key(link)+`"`) {
t.Errorf("%s = %d %s,期望只存了一份", line, status, body)
}
}
+93
View File
@@ -16,6 +16,7 @@ var (
patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
patternRedeemPairingCodeRequestCode = regexp.MustCompile(`^[2-9A-HJ-NP-Z]{8}$`)
patternRegisterRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
patternStoredMediaRefKey = regexp.MustCompile(`^[0-9a-f]{64}$`)
patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`)
)
@@ -182,6 +183,14 @@ func DecodeAdminSetGithubCredentialRequest(body []byte) (AdminSetGithubCredentia
return decodeAdminSetGithubCredentialRequest(body, "")
}
func DecodeAdminListMediaRequest(body []byte) (AdminListMediaRequest, error) {
return decodeAdminListMediaRequest(body, "")
}
func DecodeAdminRemoveMediaRequest(body []byte) (AdminRemoveMediaRequest, error) {
return decodeAdminRemoveMediaRequest(body, "")
}
func DecodeAdminUpdateSiteRequest(body []byte) (AdminUpdateSiteRequest, error) {
return decodeAdminUpdateSiteRequest(body, "")
}
@@ -346,12 +355,66 @@ func decodeAdminCreateRoomRequest(raw json.RawMessage, path string) (AdminCreate
return v, nil
}
func decodeAdminListMediaRequest(raw json.RawMessage, path string) (AdminListMediaRequest, error) {
var v AdminListMediaRequest
m, err := decodeObject(raw, path, "page", "q", "sort")
if err != nil {
return v, err
}
if r, ok := m["page"]; ok {
x, err := decodeInteger(r, field(path, "page"))
if err != nil {
return v, err
}
if err = checkMinimum(x, field(path, "page"), 1); err != nil {
return v, err
}
v.Page = &x
}
if r, ok := m["q"]; ok {
x, err := decodeString(r, field(path, "q"))
if err != nil {
return v, err
}
if err = checkMaxLength(x, field(path, "q"), 100); err != nil {
return v, err
}
v.Q = &x
}
if r, ok := m["sort"]; ok {
x, err := decodeMediaSort(r, field(path, "sort"))
if err != nil {
return v, err
}
v.Sort = &x
}
return v, nil
}
func decodeAdminOpenRegistrationRequest(raw json.RawMessage, path string) (AdminOpenRegistrationRequest, error) {
var v AdminOpenRegistrationRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminRemoveMediaRequest(raw json.RawMessage, path string) (AdminRemoveMediaRequest, error) {
var v AdminRemoveMediaRequest
m, err := decodeObject(raw, path, "media")
if err != nil {
return v, err
}
if err = requireFields(m, path, "media"); err != nil {
return v, err
}
if v.Media, err = decodeArray(decodeStoredMediaRef, 50)(m["media"], field(path, "media")); err != nil {
return v, err
}
if err = checkMinItems(v.Media, field(path, "media"), 1); err != nil {
return v, err
}
return v, nil
}
func decodeAdminResetAccountPasswordRequest(raw json.RawMessage, path string) (AdminResetAccountPasswordRequest, error) {
var v AdminResetAccountPasswordRequest
_, err := decodeObject(raw, path)
@@ -912,6 +975,18 @@ func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error)
return v, nil
}
func decodeMediaSort(raw json.RawMessage, path string) (MediaSort, error) {
s, err := decodeString(raw, path)
if err != nil {
return "", err
}
switch v := MediaSort(s); v {
case MediaSortStored, MediaSortIdle, MediaSortSize:
return v, nil
}
return "", invalid(path, "取值应为 stored、idle、size 之一")
}
func decodeMoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (MoveMyPlaylistItemsRequest, error) {
var v MoveMyPlaylistItemsRequest
m, err := decodeObject(raw, path, "before", "tracks")
@@ -1366,6 +1441,24 @@ func decodeStartGithubLoginRequest(raw json.RawMessage, path string) (StartGithu
return v, nil
}
func decodeStoredMediaRef(raw json.RawMessage, path string) (StoredMediaRef, error) {
var v StoredMediaRef
m, err := decodeObject(raw, path, "key")
if err != nil {
return v, err
}
if err = requireFields(m, path, "key"); err != nil {
return v, err
}
if v.Key, err = decodeString(m["key"], field(path, "key")); err != nil {
return v, err
}
if err = checkPattern(v.Key, field(path, "key"), patternStoredMediaRefKey); err != nil {
return v, err
}
return v, nil
}
func decodeTrack(raw json.RawMessage, path string) (Track, error) {
var v Track
m, err := decodeObject(raw, path, "id", "source")
+55 -11
View File
@@ -309,11 +309,26 @@
"source": {"$ref": "#/$defs/Source"},
"link": {"type": "string", "description": "这首歌的页面:链接曲目是链接本身,B 站的歌是视频页面"},
"title": {"type": "string"},
"artist": {"type": "string", "description": "不知道时为空字符串"},
"coverUrl": {"type": "string", "description": "可以直接打开的封面地址,没有封面时为空字符串"},
"size": {"type": "integer", "description": "音频文件的字节数"},
"storedAtMs": {"type": "integer"},
"lastPlayedAtMs": {"anyOf": [{"type": "integer"}, {"type": "null"}], "description": "没有播放过时为 null"}
},
"required": ["key", "source", "link", "title", "size", "storedAtMs", "lastPlayedAtMs"]
"required": ["key", "source", "link", "title", "artist", "coverUrl", "size", "storedAtMs", "lastPlayedAtMs"]
},
"MediaSort": {
"type": "string",
"description": "stored:存储时间从新到旧;idle:最近播放从早到晚,从没播放过的排最前;size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏",
"enum": ["stored", "idle", "size"]
},
"StoredMediaRef": {
"type": "object",
"additionalProperties": false,
"properties": {
"key": {"type": "string", "description": "StoredMedia 的 key:64 个小写十六进制字符", "pattern": "^[0-9a-f]{64}$"}
},
"required": ["key"]
},
"SourceAccount": {
"type": "object",
@@ -1544,26 +1559,55 @@
},
{
"name": "adminListMedia",
"description": "已存储的链接曲目和 B 站的歌",
"method": "GET",
"path": "/api/admin/media",
"description": "已存储的链接曲目和 B 站的歌,每页 50 首;页码超出最后一页时 items 为空,其余字段照常",
"method": "POST",
"path": "/api/admin/media/queries",
"auth": "admin",
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"page": {"$ref": "#/$defs/Page", "description": "页码,从 1 开始,缺省为 1"},
"sort": {"$ref": "#/$defs/MediaSort", "description": "缺省为 stored"},
"q": {"type": "string", "description": "按标题搜索:标题包含它就算匹配,不区分大小写,% 和 _ 按字面匹配;缺省或为空时不搜索", "maxLength": 100}
}
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"items": {"type": "array", "items": {"$ref": "#/$defs/StoredMedia"}}
"items": {"type": "array", "items": {"$ref": "#/$defs/StoredMedia"}},
"total": {"type": "integer", "description": "匹配的歌曲数,不只是这一页"},
"totalBytes": {"type": "integer", "description": "匹配的歌曲的音频字节数之和"},
"page": {"type": "integer"},
"pageSize": {"type": "integer", "description": "每页的条数,固定为 50"}
},
"required": ["items"]
"required": ["items", "total", "totalBytes", "page", "pageSize"]
}
},
{
"name": "adminDeleteMedia",
"description": "删除一首已存储的歌的音频和封面,并清除它的准备结果",
"method": "DELETE",
"path": "/api/admin/media/{key}",
"name": "adminRemoveMedia",
"description": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首:正在房间里使用的不会删除,放进 inUse;不存在的忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可",
"method": "POST",
"path": "/api/admin/media/removals",
"auth": "admin",
"status": 204
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"media": {"type": "array", "items": {"$ref": "#/$defs/StoredMediaRef"}, "minItems": 1, "maxItems": 50}
},
"required": ["media"]
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"deleted": {"type": "array", "items": {"type": "string"}, "description": "删掉的歌的 key"},
"inUse": {"type": "array", "items": {"type": "string"}, "description": "正在房间里使用、没有删除的歌的 key"}
},
"required": ["deleted", "inUse"]
}
},
{
"name": "adminUpdateSite",
+149 -19
View File
@@ -307,6 +307,26 @@
],
"type": "object"
},
"AdminListMediaRequest": {
"additionalProperties": false,
"properties": {
"page": {
"description": "页码,从 1 开始,缺省为 1",
"minimum": 1,
"type": "integer"
},
"q": {
"description": "按标题搜索:标题包含它就算匹配,不区分大小写,% 和 _ 按字面匹配;缺省或为空时不搜索",
"maxLength": 100,
"type": "string"
},
"sort": {
"$ref": "#/components/schemas/MediaSort",
"description": "缺省为 stored"
}
},
"type": "object"
},
"AdminListMediaResponse": {
"additionalProperties": false,
"properties": {
@@ -315,10 +335,29 @@
"$ref": "#/components/schemas/StoredMedia"
},
"type": "array"
},
"page": {
"type": "integer"
},
"pageSize": {
"description": "每页的条数,固定为 50",
"type": "integer"
},
"total": {
"description": "匹配的歌曲数,不只是这一页",
"type": "integer"
},
"totalBytes": {
"description": "匹配的歌曲的音频字节数之和",
"type": "integer"
}
},
"required": [
"items"
"items",
"total",
"totalBytes",
"page",
"pageSize"
],
"type": "object"
},
@@ -341,6 +380,47 @@
"additionalProperties": false,
"type": "object"
},
"AdminRemoveMediaRequest": {
"additionalProperties": false,
"properties": {
"media": {
"items": {
"$ref": "#/components/schemas/StoredMediaRef"
},
"maxItems": 50,
"minItems": 1,
"type": "array"
}
},
"required": [
"media"
],
"type": "object"
},
"AdminRemoveMediaResponse": {
"additionalProperties": false,
"properties": {
"deleted": {
"description": "删掉的歌的 key",
"items": {
"type": "string"
},
"type": "array"
},
"inUse": {
"description": "正在房间里使用、没有删除的歌的 key",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"deleted",
"inUse"
],
"type": "object"
},
"AdminResetAccountPasswordRequest": {
"additionalProperties": false,
"type": "object"
@@ -1134,6 +1214,15 @@
],
"type": "object"
},
"MediaSort": {
"description": "stored:存储时间从新到旧;idle:最近播放从早到晚,从没播放过的排最前;size:占用从大到小。三种排序都以曲目 ID 作为最后的排序键,翻页时不重复也不遗漏",
"enum": [
"stored",
"idle",
"size"
],
"type": "string"
},
"Member": {
"additionalProperties": false,
"properties": {
@@ -2048,6 +2137,14 @@
"StoredMedia": {
"additionalProperties": false,
"properties": {
"artist": {
"description": "不知道时为空字符串",
"type": "string"
},
"coverUrl": {
"description": "可以直接打开的封面地址,没有封面时为空字符串",
"type": "string"
},
"key": {
"description": "曲目 ID 的 SHA-256(十六进制),用于删除;链接曲目的 ID 是规范化之后的链接",
"type": "string"
@@ -2086,12 +2183,28 @@
"source",
"link",
"title",
"artist",
"coverUrl",
"size",
"storedAtMs",
"lastPlayedAtMs"
],
"type": "object"
},
"StoredMediaRef": {
"additionalProperties": false,
"properties": {
"key": {
"description": "StoredMedia 的 key:64 个小写十六进制字符",
"pattern": "^[0-9a-f]{64}$",
"type": "string"
}
},
"required": [
"key"
],
"type": "object"
},
"Track": {
"additionalProperties": false,
"allOf": [
@@ -2265,7 +2378,7 @@
},
"info": {
"title": "Alisten",
"version": "6bee23791ffa52ad"
"version": "ce462456c63356c6"
},
"openapi": "3.1.0",
"paths": {
@@ -3062,9 +3175,19 @@
"summary": "写入或覆盖 YouTube cookies,不返回 cookies"
}
},
"/api/admin/media": {
"get": {
"/api/admin/media/queries": {
"post": {
"operationId": "adminListMedia",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminListMediaRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
@@ -3092,24 +3215,31 @@
"adminToken": []
}
],
"summary": "已存储的链接曲目和 B 站的歌"
"summary": "已存储的链接曲目和 B 站的歌,每页 50 首;页码超出最后一页时 items 为空,其余字段照常"
}
},
"/api/admin/media/{key}": {
"delete": {
"operationId": "adminDeleteMedia",
"parameters": [
{
"in": "path",
"name": "key",
"required": true,
"schema": {
"type": "string"
"/api/admin/media/removals": {
"post": {
"operationId": "adminRemoveMedia",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminRemoveMediaRequest"
}
}
}
],
},
"required": true
},
"responses": {
"204": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminRemoveMediaResponse"
}
}
},
"description": "成功"
},
"default": {
@@ -3128,7 +3258,7 @@
"adminToken": []
}
],
"summary": "删除一首已存储的歌的音频和封面,并清除它的准备结果"
"summary": "删除若干首已存储的歌的音频和封面,并清除它们的准备结果,一次 1 到 50 首:正在房间里使用的不会删除,放进 inUse;不存在的忽略。逐首删除,中途出错时返回错误,出错之前已经删掉的不会恢复,用同样的请求重试即可"
}
},
"/api/admin/registration": {
+56 -4
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "6bee23791ffa52ad"
const Version = "ce462456c63356c6"
type APIKey struct {
Key string `json:"key"`
@@ -95,8 +95,18 @@ type AdminGetYoutubeCredentialResponse struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AdminListMediaRequest struct {
Page *int64 `json:"page,omitempty"`
Q *string `json:"q,omitempty"`
Sort *MediaSort `json:"sort,omitempty"`
}
type AdminListMediaResponse struct {
Items []StoredMedia `json:"items"`
Items []StoredMedia `json:"items"`
Page int64 `json:"page"`
PageSize int64 `json:"pageSize"`
Total int64 `json:"total"`
TotalBytes int64 `json:"totalBytes"`
}
func (v AdminListMediaResponse) MarshalJSON() ([]byte, error) {
@@ -121,6 +131,34 @@ func (v AdminListRoomsResponse) MarshalJSON() ([]byte, error) {
type AdminOpenRegistrationRequest struct{}
type AdminRemoveMediaRequest struct {
Media []StoredMediaRef `json:"media"`
}
func (v AdminRemoveMediaRequest) MarshalJSON() ([]byte, error) {
type plain AdminRemoveMediaRequest
if v.Media == nil {
v.Media = []StoredMediaRef{}
}
return json.Marshal(plain(v))
}
type AdminRemoveMediaResponse struct {
Deleted []string `json:"deleted"`
InUse []string `json:"inUse"`
}
func (v AdminRemoveMediaResponse) MarshalJSON() ([]byte, error) {
type plain AdminRemoveMediaResponse
if v.Deleted == nil {
v.Deleted = []string{}
}
if v.InUse == nil {
v.InUse = []string{}
}
return json.Marshal(plain(v))
}
type AdminResetAccountPasswordRequest struct{}
type AdminResetAccountPasswordResponse struct {
@@ -444,6 +482,14 @@ type LoginResponse struct {
MemberToken string `json:"memberToken"`
}
type MediaSort string
const (
MediaSortStored MediaSort = "stored"
MediaSortIdle MediaSort = "idle"
MediaSortSize MediaSort = "size"
)
type Member struct {
AvatarHash string `json:"avatarHash"`
ID string `json:"id"`
@@ -854,6 +900,8 @@ type StartGithubLoginResponse struct {
}
type StoredMedia struct {
Artist string `json:"artist"`
CoverURL string `json:"coverUrl"`
Key string `json:"key"`
LastPlayedAtMs *int64 `json:"lastPlayedAtMs"`
Link string `json:"link"`
@@ -863,6 +911,10 @@ type StoredMedia struct {
Title string `json:"title"`
}
type StoredMediaRef struct {
Key string `json:"key"`
}
type Track struct {
ID string `json:"id"`
Source Source `json:"source"`
@@ -985,8 +1037,8 @@ var (
RouteAdminGetGithubCredential = Route{Pattern: "GET /api/admin/credentials/github", Auth: AuthAdmin, Status: 200}
RouteAdminSetGithubCredential = Route{Pattern: "PUT /api/admin/credentials/github", Auth: AuthAdmin, Status: 204}
RouteAdminDeleteGithubCredential = Route{Pattern: "DELETE /api/admin/credentials/github", Auth: AuthAdmin, Status: 204}
RouteAdminListMedia = Route{Pattern: "GET /api/admin/media", Auth: AuthAdmin, Status: 200}
RouteAdminDeleteMedia = Route{Pattern: "DELETE /api/admin/media/{key}", Auth: AuthAdmin, Status: 204}
RouteAdminListMedia = Route{Pattern: "POST /api/admin/media/queries", Auth: AuthAdmin, Status: 200}
RouteAdminRemoveMedia = Route{Pattern: "POST /api/admin/media/removals", Auth: AuthAdmin, Status: 200}
RouteAdminUpdateSite = Route{Pattern: "PATCH /api/admin/site", Auth: AuthAdmin, Status: 200}
RouteAdminOpenRegistration = Route{Pattern: "PUT /api/admin/registration", Auth: AuthAdmin, Status: 204}
RouteAdminCloseRegistration = Route{Pattern: "DELETE /api/admin/registration", Auth: AuthAdmin, Status: 204}
+1 -1
View File
@@ -22,7 +22,7 @@ server {
include /tmp/alisten.d/*.conf;
location ~ ^/(?:admin|settings|history|playlists(?:/[^/]+)?)?$ {
location ~ ^/(?:settings(?:/(?:account|appearance|playback|performance|privacy))?|admin(?:/(?:site|registration|users|rooms|sources|media))?|history|playlists(?:/[^/]+)?)?$ {
add_header Cache-Control "no-cache";
try_files /index.html =404;
}
+41 -5
View File
@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react'
import type { Page, Panel, RoomPage } from './route'
import type { Page, Panel, RoomPage, Route } from './route'
import type { SearchQuery } from '@/features/search'
import { ChevronLeft, ChevronRight, Headphones, History, House, Library, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react'
import { Suspense } from 'react'
@@ -47,6 +47,7 @@ const mobileMenu: { page: RoomPage, label: string, icon: LucideIcon }[] = [
]
const pageClasses: Partial<Record<Page, string>> = {
'admin': 'settings-view',
'queue': 'scrollbar-hidden',
'history': 'scrollbar-hidden',
'playlists': 'scrollbar-hidden',
@@ -188,6 +189,41 @@ function RoutedLobby({ onEntered }: { onEntered: (ref: string) => void }) {
return <Lobby filter={route.filter} onFilter={filter => navigationStore.navigate({ ...route, filter })} onEntered={onEntered} />
}
function RoutedSettings() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'settings')
return null
return (
<SettingsPage
section={route.section}
onSection={section => section === null ? navigationStore.up({ ...route, section }) : navigationStore.navigate({ ...route, section })}
onOpenHistory={() => navigationStore.open('history')}
/>
)
}
function RoutedAdmin({ onEntered }: { onEntered: (ref: string) => void }) {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'admin')
return null
return (
<AdminPage
section={route.section}
q={route.q}
page={route.page}
sort={route.sort}
onNavigate={({ section, q = '', page = 1, sort = 'stored' }) => {
const next: Route = { name: 'admin', section, panel: route.panel, q, page, sort }
if (section === null)
navigationStore.up(next)
else
navigationStore.navigate(next)
}}
onEntered={onEntered}
/>
)
}
function RoutedHistory() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'history')
@@ -213,7 +249,7 @@ function RoutedGitHubLogin() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'github-login')
return null
return <GitHubLoginPage fragment={route.fragment} onDone={() => navigationStore.replace({ name: 'settings', panel: null })} />
return <GitHubLoginPage fragment={route.fragment} onDone={() => navigationStore.replace({ name: 'settings', section: 'account', panel: null })} />
}
function RoutedSearch() {
@@ -287,7 +323,7 @@ export function App() {
<ErrorBoundary>
<Suspense fallback={pageLoading}>
{page === 'lobby' && <RoutedLobby onEntered={showRoom} />}
{page === 'admin' && <AdminPage onEntered={showRoom} />}
{page === 'admin' && <RoutedAdmin onEntered={showRoom} />}
{page === 'listen' && <RoomGate onBrowse={browse}><ListenPage /></RoomGate>}
{page === 'queue' && <RoomGate onBrowse={browse}><QueuePage /></RoomGate>}
{page === 'search' && <RoomGate onBrowse={browse}><RoutedSearch /></RoomGate>}
@@ -297,11 +333,11 @@ export function App() {
{page === 'playlists' && (
<PlaylistsPage
onOpen={playlist => navigationStore.navigate({ name: 'my-playlist', id: playlist.id, panel: navigationStore.state.route.panel }, playlist.name)}
onRegister={() => navigationStore.open('settings')}
onRegister={() => navigationStore.navigate({ name: 'settings', section: 'account', panel: navigationStore.state.route.panel })}
/>
)}
{page === 'my-playlist' && <RoutedMyPlaylist />}
{page === 'settings' && <SettingsPage />}
{page === 'settings' && <RoutedSettings />}
{page === 'shared-playlist' && <RoutedSharedPlaylist />}
{page === 'pair' && <RoutedPair />}
{page === 'github-login' && <RoutedGitHubLogin />}
+1 -1
View File
@@ -28,7 +28,7 @@ volumeStore.start()
brandStore.start()
profileStore.start()
playlistsStore.start()
setAccountSettingsOpener(() => navigationStore.open('settings'))
setAccountSettingsOpener(() => navigationStore.navigate({ name: 'settings', section: 'account', panel: navigationStore.state.route.panel }))
playerStore.start()
reminderStore.start()
historyStore.start()
+15 -2
View File
@@ -4,7 +4,7 @@ import { roomEntryStore } from '@/features/rooms'
import { searchStore } from '@/features/search'
import { canGoBack, canGoForward, currentEntry, goBack, startHistory, writeEntry } from '@/shared/lib/session-history'
import { Store } from '@/shared/lib/store'
import { formatRoute, isNewEntry, leaveStep, parseRoute } from './route'
import { formatRoute, isNewEntry, leaveStep, parseRoute, upStep } from './route'
interface NavigationState {
route: Route
@@ -65,12 +65,25 @@ class NavigationStore extends Store<NavigationState> {
this.#show(route)
}
// 从设置或后台的分类回到分类列表,不新增记录。
up(list: Route): void {
const { position, from } = currentEntry()
if (upStep(list, from, canGoBack(position)) === 'back')
goBack()
else
this.replace(list)
}
// 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。
open(page: 'lobby' | 'admin' | 'settings' | 'history' | 'playlists' | RoomPage): void {
const { panel } = this.state.route
if (page === 'lobby' || page === 'history')
this.navigate({ name: page, filter: '', panel })
else if (page === 'admin' || page === 'settings' || page === 'playlists')
else if (page === 'settings')
this.navigate({ name: page, section: null, panel })
else if (page === 'admin')
this.navigate({ name: page, section: null, panel, q: '', page: 1, sort: 'stored' })
else if (page === 'playlists')
this.navigate({ name: page, panel })
else
this.#openRoomPage(ref => ({ name: page, ref, panel }))
+65 -6
View File
@@ -1,7 +1,9 @@
import type { Route } from './route'
import type { MediaSort } from '@/core/realtime'
import type { AdminSection } from '@/features/admin'
import type { SearchQuery } from '@/features/search'
import { describe, expect, it } from 'vitest'
import { formatRoute, isNewEntry, leaveStep, parseRoute } from './route'
import { formatRoute, isNewEntry, leaveStep, parseRoute, upStep } from './route'
function parse(url: string): Route | null {
return parseRoute(new URL(url, 'https://alisten.test'))
@@ -11,8 +13,12 @@ describe('parseRoute', () => {
it.each<[string, Route]>([
['/', { name: 'lobby', filter: '', panel: null }],
['/?q=+%E5%A4%9C&panel=chat', { name: 'lobby', filter: ' 夜', panel: 'chat' }],
['/admin', { name: 'admin', panel: null }],
['/settings?panel=queue', { name: 'settings', panel: 'queue' }],
['/admin', { name: 'admin', section: null, panel: null, q: '', page: 1, sort: 'stored' }],
['/admin/site', { name: 'admin', section: 'site', panel: null, q: '', page: 1, sort: 'stored' }],
['/admin/rooms?q=jazz&page=2&panel=chat', { name: 'admin', section: 'rooms', panel: 'chat', q: 'jazz', page: 2, sort: 'stored' }],
['/admin/media?q=lemon&sort=size&page=3', { name: 'admin', section: 'media', panel: null, q: 'lemon', page: 3, sort: 'size' }],
['/settings?panel=queue', { name: 'settings', section: null, panel: 'queue' }],
['/settings/privacy', { name: 'settings', section: 'privacy', panel: null }],
['/history', { name: 'history', filter: '', panel: null }],
['/history?q=%E6%99%B4&panel=history', { name: 'history', filter: '晴', panel: 'history' }],
['/playlists', { name: 'playlists', panel: null }],
@@ -36,6 +42,9 @@ describe('parseRoute', () => {
it.each([
['/admin?from=menu', '/admin'],
['/admin/users?q=a&page=2&sort=size', '/admin/users'],
['/admin/rooms?sort=size', '/admin/rooms'],
['/admin/media?sort=idle&page=1', '/admin/media?sort=idle'],
['/settings?panel=lyrics', '/settings'],
['/?q=', '/'],
['/r/my-room?panel=lyrics', '/r/my-room'],
@@ -46,15 +55,39 @@ describe('parseRoute', () => {
['/r/my-room/search?view=tracks&page=2', '/r/my-room/search?view=tracks'],
['/r/my-room/search?view=recommend&q=a&source=netease&page=2', '/r/my-room/search?view=recommend'],
['/r/my-room/playlists/netease/123?page=1.5', '/r/my-room/playlists/netease/123'],
['/admin/media?page=99999999999999999999', '/admin/media'],
['/admin/media?page=1e300', '/admin/media'],
['/admin/media?page=9007199254740991', '/admin/media?page=9007199254740991'],
['/r/my-room/playlists/netease/123?page=99999999999999999999', '/r/my-room/playlists/netease/123'],
])('%s 规范化为 %s', (url, canonical) => {
const route = parse(url)
expect(route && formatRoute(route)).toBe(canonical)
})
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => {
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/settings/', '/settings/account/extra', '/admin/', '/admin/overview', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => {
expect(parse(url)).toBeNull()
})
it('解析设置和后台的分类', () => {
expect(parseRoute({ pathname: '/settings/appearance', search: '', hash: '' })).toEqual({ name: 'settings', section: 'appearance', panel: null })
expect(parseRoute({ pathname: '/settings', search: '', hash: '' })).toEqual({ name: 'settings', section: null, panel: null })
expect(parseRoute({ pathname: '/settings/unknown', search: '', hash: '' })).toBeNull()
expect(parseRoute({ pathname: '/admin/media', search: '?q=lemon&sort=size&page=3', hash: '' })).toEqual({ name: 'admin', section: 'media', panel: null, q: 'lemon', page: 3, sort: 'size' })
expect(parseRoute({ pathname: '/admin/media', search: '?sort=bogus&page=0', hash: '' })).toEqual({ name: 'admin', section: 'media', panel: null, q: '', page: 1, sort: 'stored' })
})
it('媒体的搜索词截到接口允许的 100 个字符,按码点计;房间的搜索词在本地筛选,不截', () => {
const long = `${'a'.repeat(99)}🎵🎵`
expect(parse(`/admin/media?q=${encodeURIComponent(long)}`)).toMatchObject({ section: 'media', q: `${'a'.repeat(99)}🎵` })
expect(parse(`/admin/media?q=${'b'.repeat(100)}`)).toMatchObject({ q: 'b'.repeat(100) })
expect(parse(`/admin/rooms?q=${'c'.repeat(150)}`)).toMatchObject({ section: 'rooms', q: 'c'.repeat(150) })
})
it('写回地址时省略默认值', () => {
expect(formatRoute({ name: 'admin', section: 'media', panel: null, q: '', page: 1, sort: 'stored' })).toBe('/admin/media')
expect(formatRoute({ name: 'admin', section: 'rooms', panel: null, q: 'jazz', page: 2, sort: 'stored' })).toBe('/admin/rooms?q=jazz&page=2')
})
it('配对码从片段中读出,写回的地址不再带它', () => {
const route = parse('/pair?panel=chat#ABCD2345')
expect(route).toEqual({ name: 'pair', code: 'ABCD2345', panel: 'chat' })
@@ -80,10 +113,20 @@ describe('isNewEntry', () => {
it('开关侧栏和输入筛选词只替换当前地址', () => {
expect(isNewEntry({ name: 'queue', ref: 'a', panel: null }, { name: 'queue', ref: 'a', panel: 'chat' })).toBe(false)
expect(isNewEntry({ name: 'settings', panel: null }, { name: 'settings', panel: 'chat' })).toBe(false)
expect(isNewEntry({ name: 'settings', section: null, panel: null }, { name: 'settings', section: null, panel: 'chat' })).toBe(false)
expect(isNewEntry({ name: 'history', filter: '晴', panel: null }, { name: 'history', filter: '晴天', panel: null })).toBe(false)
})
it('换分类写入新记录,换搜索词、排序和页码只替换当前地址', () => {
const admin = (section: AdminSection | null, q: string, page: number, sort: MediaSort): Route => ({ name: 'admin', section, panel: null, q, page, sort })
expect(isNewEntry({ name: 'settings', section: null, panel: null }, { name: 'settings', section: 'account', panel: null })).toBe(true)
expect(isNewEntry({ name: 'settings', section: 'account', panel: null }, { name: 'settings', section: 'privacy', panel: null })).toBe(true)
expect(isNewEntry({ name: 'settings', section: 'privacy', panel: null }, { name: 'settings', section: null, panel: null })).toBe(true)
expect(isNewEntry(admin('rooms', '', 1, 'stored'), admin('media', '', 1, 'stored'))).toBe(true)
expect(isNewEntry(admin('media', '', 1, 'stored'), admin('media', 'lemon', 1, 'stored'))).toBe(false)
expect(isNewEntry(admin('media', 'lemon', 1, 'stored'), admin('media', 'lemon', 2, 'size'))).toBe(false)
})
it('打开另一个自己的歌单写入新记录,同一个歌单只换侧栏时替换当前地址', () => {
expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'b', panel: null })).toBe(true)
expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'a', panel: 'queue' })).toBe(false)
@@ -112,11 +155,27 @@ describe('isNewEntry', () => {
})
})
describe('upStep', () => {
const list: Route = { name: 'settings', section: null, panel: null }
it('上一条记录就是分类列表时后退,不新增记录', () => {
expect(upStep(list, '/settings', true)).toBe('back')
expect(upStep({ name: 'admin', section: null, panel: null, q: '', page: 1, sort: 'stored' }, '/admin', true)).toBe('back')
})
it('从别的页面直接打开分类、或者没有可以后退的记录时,把当前记录换成分类列表', () => {
expect(upStep(list, '/playlists', true)).toBe('replace')
expect(upStep(list, '/settings/account', true)).toBe('replace')
expect(upStep(list, undefined, true)).toBe('replace')
expect(upStep(list, '/settings', false)).toBe('replace')
})
})
describe('leaveStep', () => {
const leaving = new Set(['my-room', 'a1b2c3'])
it('不在这个房间的页面上时留在原处', () => {
expect(leaveStep({ name: 'admin', panel: null }, leaving, true)).toBe('stay')
expect(leaveStep({ name: 'admin', section: null, panel: null, q: '', page: 1, sort: 'stored' }, leaving, true)).toBe('stay')
expect(leaveStep({ name: 'listen', ref: 'other-room', panel: null }, leaving, true)).toBe('stay')
})
+66 -10
View File
@@ -1,6 +1,8 @@
import type { Source } from '@/core/realtime'
import type { MediaSort, Source } from '@/core/realtime'
import type { AdminSection } from '@/features/admin'
import type { SearchSource } from '@/features/history'
import type { SearchQuery } from '@/features/search'
import type { SettingsSection } from '@/features/settings'
import { canOpenPlaylists, defaultSearchSource, keywordSearch } from '@/features/search'
export type RoomPage = 'listen' | 'queue' | 'chat'
@@ -9,8 +11,10 @@ export type Panel = 'queue' | 'history' | 'chat'
// panel 是宽屏右侧的侧栏,和当前页面相同时不显示。
export type Route
= | { name: 'lobby', filter: string, panel: Panel | null }
| { name: 'admin', panel: Panel | null }
| { name: 'settings', panel: Panel | null }
// section 为 null 时地址是 /admin 或 /settings:宽屏显示第一个分类,窄屏显示分类列表。
// q 和 page 只用于后台的房间和媒体,sort 只用于媒体。
| { name: 'admin', section: AdminSection | null, panel: Panel | null, q: string, page: number, sort: MediaSort }
| { name: 'settings', section: SettingsSection | null, panel: Panel | null }
| { name: 'history', filter: string, panel: Panel | null }
| { name: 'playlists', panel: Panel | null }
| { name: 'my-playlist', id: string, panel: Panel | null }
@@ -25,6 +29,20 @@ export type Route
export type Page = Route['name']
const settingsSections: Record<SettingsSection, true> = { account: true, appearance: true, playback: true, performance: true, privacy: true }
const adminSections: Record<AdminSection, true> = { site: true, registration: true, users: true, rooms: true, sources: true, media: true }
const mediaSorts: Record<MediaSort, true> = { stored: true, idle: true, size: true }
// 与接口对媒体搜索词的长度限制一致,按码点计:手改或截断的链接里更长的搜索词会让每次读取都失败。
const mediaQueryMaxLength = 100
function isOneOf<T extends string>(values: Record<T, true>, value: string): value is T {
return Object.hasOwn(values, value)
}
function isPaged(section: AdminSection | null): boolean {
return section === 'rooms' || section === 'media'
}
function parseSource(value: string | null): SearchSource | null {
return value === 'qq' || value === 'netease' ? value : null
}
@@ -34,9 +52,15 @@ function parsePanel(params: URLSearchParams): Panel | null {
return panel === 'queue' || panel === 'history' || panel === 'chat' ? panel : null
}
// 超出安全整数的页码写回地址时会变成另一个数,接口也不接受:当作第 1 页。
function parsePage(params: URLSearchParams): number {
const page = Number(params.get('page'))
return Number.isInteger(page) && page > 1 ? page : 1
return Number.isSafeInteger(page) && page > 1 ? page : 1
}
function parseAdminQuery(section: AdminSection | null, params: URLSearchParams): string {
const q = isPaged(section) ? params.get('q') ?? '' : ''
return section === 'media' ? [...q].slice(0, mediaQueryMaxLength).join('') : q
}
function parseQuery(params: URLSearchParams): SearchQuery {
@@ -69,10 +93,26 @@ export function parseRoute({ pathname, search, hash }: Pick<Location, 'pathname'
const panel = parsePanel(params)
if (pathname === '/')
return { name: 'lobby', filter: params.get('q') ?? '', panel }
if (pathname === '/admin')
return { name: 'admin', panel }
if (pathname === '/settings')
return { name: 'settings', panel }
const settings = /^\/settings(?:\/([^/]+))?$/.exec(pathname)
if (settings) {
const [, section = null] = settings
return section === null || isOneOf(settingsSections, section) ? { name: 'settings', section, panel } : null
}
const admin = /^\/admin(?:\/([^/]+))?$/.exec(pathname)
if (admin) {
const [, section = null] = admin
if (section !== null && !isOneOf(adminSections, section))
return null
const sort = params.get('sort') ?? ''
return {
name: 'admin',
section,
panel,
q: parseAdminQuery(section, params),
page: isPaged(section) ? parsePage(params) : 1,
sort: section === 'media' && isOneOf(mediaSorts, sort) ? sort : 'stored',
}
}
if (pathname === '/history')
return { name: 'history', filter: params.get('q') ?? '', panel }
if (pathname === '/playlists')
@@ -115,6 +155,7 @@ function pathOf(route: Route): string {
return '/'
case 'admin':
case 'settings':
return route.section ? `/${route.name}/${route.section}` : `/${route.name}`
case 'history':
case 'playlists':
return `/${route.name}`
@@ -143,17 +184,27 @@ export function formatRoute(route: Route): string {
writeQuery(route.query, params)
if (route.name === 'playlist' && route.page > 1)
params.set('page', String(route.page))
if (route.name === 'admin' && isPaged(route.section)) {
if (route.q)
params.set('q', route.q)
if (route.section === 'media' && route.sort !== 'stored')
params.set('sort', route.sort)
if (route.page > 1)
params.set('page', String(route.page))
}
if (route.panel)
params.set('panel', route.panel)
const query = params.toString()
return query ? `${pathOf(route)}?${query}` : pathOf(route)
}
// 换页面、进入另一个房间、提交新的关键词和打开另一个歌单写入新记录,后退一次回到上一个页面或上一次搜索;
// 同一个页面里换展示方式、开关侧栏和输入筛选词只替换当前地址。
// 换页面、进入另一个房间、换设置或后台的分类、提交新的关键词和打开另一个歌单写入新记录,后退一次回到上一个页面、分类或上一次搜索;
// 同一个页面里换展示方式、开关侧栏、输入筛选词、排序和翻页只替换当前地址。
export function isNewEntry(from: Route, to: Route): boolean {
if (from.name !== to.name || ('ref' in from && 'ref' in to && from.ref !== to.ref))
return true
if ((from.name === 'settings' && to.name === 'settings') || (from.name === 'admin' && to.name === 'admin'))
return from.section !== to.section
if (from.name === 'my-playlist' && to.name === 'my-playlist')
return from.id !== to.id
if (from.name === 'search' && to.name === 'search')
@@ -165,6 +216,11 @@ export function isNewEntry(from: Route, to: Route): boolean {
return false
}
// 从设置或后台的分类回到分类列表:上一条记录就是列表时后退,否则把当前记录换成列表。两种都不新增记录,返回键不会再回到刚离开的分类。
export function upStep(list: Route, from: string | undefined, canBack: boolean): 'back' | 'replace' {
return canBack && from === formatRoute(list) ? 'back' : 'replace'
}
// 取消进入房间时离开这个房间的地址:在它的页面上就后退,一直退到进入房间之前的页面;没有可以后退的记录(直接打开的房间链接)时换成发现房间;不在它的页面上就留在原处。
export function leaveStep(route: Route, leaving: ReadonlySet<string>, canBack: boolean): 'back' | 'lobby' | 'stay' {
if (!('ref' in route) || !leaving.has(route.ref))
+22 -3
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { APIKey, Account, AdminAccount, ErrorInfo, GitHubClient, GitHubIntent, ImportedTrack, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SharedPlaylist, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen'
import type { APIKey, Account, AdminAccount, ErrorInfo, GitHubClient, GitHubIntent, ImportedTrack, MediaSort, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SharedPlaylist, Source, SourceAccount, StoredMedia, StoredMediaRef, Track } from '../realtime/protocol.gen'
export interface AddMyPlaylistItemRequest {
track: Track
@@ -65,8 +65,18 @@ export interface AdminGetYoutubeCredentialResponse {
updatedAtMs: number
}
export interface AdminListMediaRequest {
page?: number
q?: string
sort?: MediaSort
}
export interface AdminListMediaResponse {
items: StoredMedia[]
page: number
pageSize: number
total: number
totalBytes: number
}
export interface AdminListRoomsResponse {
@@ -75,6 +85,15 @@ export interface AdminListRoomsResponse {
export type AdminOpenRegistrationRequest = Record<string, never>
export interface AdminRemoveMediaRequest {
media: StoredMediaRef[]
}
export interface AdminRemoveMediaResponse {
deleted: string[]
inUse: string[]
}
export type AdminResetAccountPasswordRequest = Record<string, never>
export interface AdminResetAccountPasswordResponse {
@@ -391,8 +410,8 @@ export function httpClient(send: HttpSend) {
adminGetGithubCredential: () => send<AdminGetGithubCredentialResponse>({ method: 'GET', path: '/api/admin/credentials/github', auth: 'admin' }),
adminSetGithubCredential: (body: AdminSetGithubCredentialRequest) => send<void>({ method: 'PUT', path: '/api/admin/credentials/github', auth: 'admin', body }),
adminDeleteGithubCredential: () => send<void>({ method: 'DELETE', path: '/api/admin/credentials/github', auth: 'admin' }),
adminListMedia: () => send<AdminListMediaResponse>({ method: 'GET', path: '/api/admin/media', auth: 'admin' }),
adminDeleteMedia: (key: string) => send<void>({ method: 'DELETE', path: `/api/admin/media/${encodeURIComponent(key)}`, auth: 'admin' }),
adminListMedia: (body: AdminListMediaRequest) => send<AdminListMediaResponse>({ method: 'POST', path: '/api/admin/media/queries', auth: 'admin', body }),
adminRemoveMedia: (body: AdminRemoveMediaRequest) => send<AdminRemoveMediaResponse>({ method: 'POST', path: '/api/admin/media/removals', auth: 'admin', body }),
adminUpdateSite: (body: AdminUpdateSiteRequest) => send<AdminUpdateSiteResponse>({ method: 'PATCH', path: '/api/admin/site', auth: 'admin', body }),
adminOpenRegistration: () => send<void>({ method: 'PUT', path: '/api/admin/registration', auth: 'admin', body: {} }),
adminCloseRegistration: () => send<void>({ method: 'DELETE', path: '/api/admin/registration', auth: 'admin' }),
+9 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '6bee23791ffa52ad'
export const PROTOCOL_VERSION = 'ce462456c63356c6'
export interface APIKey {
key: string
@@ -76,6 +76,8 @@ export interface ImportedTrack {
track: Track
}
export type MediaSort = 'stored' | 'idle' | 'size'
export interface Member {
avatarHash: string
id: string
@@ -308,6 +310,8 @@ export interface SourceAccount {
}
export interface StoredMedia {
artist: string
coverUrl: string
key: string
lastPlayedAtMs: number | null
link: string
@@ -317,6 +321,10 @@ export interface StoredMedia {
title: string
}
export interface StoredMediaRef {
key: string
}
export interface Track {
id: string
source: Source
+99 -56
View File
@@ -1,80 +1,123 @@
import type { AdminAccount } from '@/core/realtime'
import { useState } from 'react'
import { api } from '@/core/api'
import { ConfirmDialog } from '@/shared/ui'
import { Search } from 'lucide-react'
import { useRef, useState } from 'react'
import { api, ApiError } from '@/core/api'
import { Avatar, ConfirmDialog, notificationStore, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
import { LoadFailed, SectionHead } from './section-head'
// 新密码只在重置的响应里出现一次,离开这个分类就不再显示。
type Lookup
= | { phase: 'idle' }
| { phase: 'searching' }
| { phase: 'found', account: AdminAccount, password: string | null }
| { phase: 'missing' }
| { phase: 'failed', message: string }
async function copyPassword(password: string): Promise<void> {
try {
await navigator.clipboard.writeText(password)
notificationStore.success('已复制新密码')
}
catch {
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权。
notificationStore.info('没能写入剪贴板,请手动复制新密码。', 8000)
}
}
function findAccount(username: string): Promise<AdminAccount | null> {
return adminStore.attempt(() => api.adminGetAccount(username).catch((error: unknown) => {
if (error instanceof ApiError && error.code === 'NOT_FOUND')
return null
throw error
}))
}
function NewPassword({ password }: { password: string }) {
return (
<div className="admin-notice">
<p>新密码只显示这一次,请转告用户。这个账号在所有设备上都已退出登录。</p>
<div className="admin-row-actions">
<code className="admin-code admin-secret">{password}</code>
<button type="button" className="button-secondary" onClick={() => void copyPassword(password)}>复制</button>
</div>
</div>
)
}
export function Accounts() {
const [username, setUsername] = useState('')
const [found, setFound] = useState<AdminAccount | null>(null)
const [password, setPassword] = useState<string | null>(null)
const [lookup, setLookup] = useState<Lookup>({ phase: 'idle' })
const [confirming, setConfirming] = useState(false)
const [busy, setBusy] = useState(false)
// 查找进行中又按了回车时,以最后一次为准:先发出的查找晚回来也不覆盖,结果和输入框里的用户名一致。
const generationRef = useRef(0)
const find = async () => {
setBusy(true)
if (username === '')
return
const generation = ++generationRef.current
setLookup({ phase: 'searching' })
let next: Lookup
try {
setFound(await adminStore.call(() => api.adminGetAccount(username), { NOT_FOUND: '没有这个用户名的账号。' }))
setPassword(null)
const account = await findAccount(username)
next = account ? { phase: 'found', account, password: null } : { phase: 'missing' }
}
finally {
setBusy(false)
catch (error) {
next = { phase: 'failed', message: (error as Error).message }
}
}
const reset = async (account: AdminAccount) => {
const result = await adminStore.call(() => api.adminResetAccountPassword(account.username))
if (result !== null)
setPassword(result.password)
setConfirming(false)
if (generation === generationRef.current)
setLookup(next)
}
return (
<section className="admin-section" aria-labelledby="admin-accounts-title">
<div className="section-heading">
<h2 id="admin-accounts-title" className="text-ui-lg">账号</h2>
</div>
<section className="admin-section" aria-labelledby="admin-users-title">
<SectionHead id="admin-users-title" title="用户" description="用户忘记密码时,在这里按用户名找到账号,然后重置密码。" />
<form
className="admin-list"
className="admin-toolbar"
role="search"
onSubmit={(event) => {
event.preventDefault()
void find()
}}
>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-account-username" className="admin-row-title text-ui-base">查找账号</label>
<span id="admin-account-username-description" className="text-ui-sm muted">按用户名查找。用户忘记密码时,可以在这里重置。</span>
</div>
<div className="admin-row-actions">
<input id="admin-account-username" aria-describedby="admin-account-username-description" autoComplete="off" value={username} onChange={event => setUsername(event.target.value.trim().toLowerCase())} />
<button type="submit" className="button-secondary" disabled={busy || username === ''}>查找</button>
</div>
</div>
{found && (
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">{found.username}</span>
<span className="text-ui-sm muted">{`昵称:${found.member.name}`}</span>
</div>
<div className="admin-row-actions">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input type="search" enterKeyHint="search" aria-label="用户名" placeholder="输入用户名,按回车查找" autoComplete="off" spellCheck={false} value={username} onChange={event => setUsername(event.target.value.trim().toLowerCase())} />
</label>
</form>
{lookup.phase === 'searching' && <Skeleton rows={1} />}
{lookup.phase === 'failed' && <LoadFailed message={lookup.message} onRetry={() => void find()} />}
{lookup.phase === 'missing' && <p className="admin-hint" role="status">没有这个用户名的账号。</p>}
{lookup.phase === 'found' && (
<>
<div className="section-group">
<div className="section-row">
<Avatar hash={lookup.account.member.avatarHash} name={lookup.account.member.name} className="admin-user-avatar text-ui-base" />
<div className="section-row-copy">
<span className="section-row-title truncate">{lookup.account.member.name}</span>
<span className="section-row-description">{`@${lookup.account.username}`}</span>
</div>
<button type="button" className="button-secondary" onClick={() => setConfirming(true)}>重置密码</button>
</div>
</div>
)}
{found && password && (
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">
新密码:
<code>{password}</code>
</span>
<span className="text-ui-sm muted">只显示这一次,请转告用户。这个账号在所有设备上都已退出登录。</span>
</div>
</div>
)}
</form>
{found && confirming && (
<ConfirmDialog title="重置密码" confirmLabel="重置" danger onConfirm={() => reset(found)} onClose={() => setConfirming(false)}>
{`将为 ${found.username} 生成一个新密码,原来的密码立即失效,这个账号在所有设备上都会退出登录。`}
</ConfirmDialog>
{lookup.password !== null && <NewPassword password={lookup.password} />}
{confirming && (
<ConfirmDialog
title="重置密码"
confirmLabel="重置"
danger
onConfirm={async () => {
const { account } = lookup
const result = await adminStore.call(() => api.adminResetAccountPassword(account.username))
if (result === null)
return
setLookup({ phase: 'found', account, password: result.password })
setConfirming(false)
}}
onClose={() => setConfirming(false)}
>
{`将为 ${lookup.account.username} 生成一个新密码,原来的密码立即失效,这个账号在所有设备上都会退出登录。`}
</ConfirmDialog>
)}
</>
)}
</section>
)
+90 -48
View File
@@ -1,66 +1,108 @@
import { LogOut } from 'lucide-react'
import type { MediaSort } from '@/core/realtime'
import type { SectionItem } from '@/shared/ui'
import { Database, DoorOpen, KeyRound, LogOut, RadioTower, Store, Users } from 'lucide-react'
import { useState } from 'react'
import { brandStore } from '@/core/brand'
import { useStore } from '@/shared/lib/use-store'
import { SectionLayout } from '@/shared/ui'
import { Accounts } from './accounts'
import { adminStore } from './admin-store'
import { GithubApp } from './github-app'
import { PersistentRooms } from './persistent-rooms'
import { Registration } from './registration'
import { SiteSettings } from './site-settings'
import { needsSignIn } from './source-account-view'
import { SourceAccounts } from './source-accounts'
import { StoredMedia } from './stored-media'
import { useSourceCredentials } from './source-credentials'
import { MediaPage } from './stored-media'
import './admin.css'
function SignIn() {
const [token, setToken] = useState('')
return (
<form
className="form-stack admin-sign-in"
onSubmit={(event) => {
event.preventDefault()
adminStore.signIn(token)
}}
>
<label>
管理 token
<input type="password" autoComplete="off" autoFocus required value={token} onChange={event => setToken(event.target.value)} />
</label>
<p className="text-ui-caption muted">管理 token 只保存在这个标签页中,关闭标签页后要重新输入。</p>
<button type="submit" className="button-primary" disabled={!token}>进入后台</button>
</form>
)
export type AdminSection = 'site' | 'registration' | 'users' | 'rooms' | 'sources' | 'media'
// section 为 null 表示窄屏回到分类列表;没有给出的 q、page、sort 取默认值。
interface AdminView {
section: AdminSection | null
q?: string
page?: number
sort?: MediaSort
}
export function AdminPage({ onEntered }: { onEntered: (ref: string) => void }) {
const signedIn = useStore(adminStore, s => s.signedIn)
interface AdminPageProps {
section: AdminSection | null
q: string
page: number
sort: MediaSort
onNavigate: (view: AdminView) => void
onEntered: (ref: string) => void
}
const sections: SectionItem<AdminSection>[] = [
{ id: 'site', label: '站点', icon: Store },
{ id: 'registration', label: '登录与注册', icon: KeyRound },
{ id: 'users', label: '用户', icon: Users },
{ id: 'rooms', label: '房间', icon: DoorOpen },
{ id: 'sources', label: '音源', icon: RadioTower },
{ id: 'media', label: '媒体', icon: Database },
]
function SignIn() {
const brandName = useStore(brandStore, s => s.name)
const [token, setToken] = useState('')
return (
<div className="page-content admin-page">
<div className="admin-heading">
<div className="page-title">
<span className="text-ui-sm accent">{brandName}</span>
<h1 className="text-ui-xl">后台</h1>
<p className="text-ui-base muted">管理品牌名、注册、GitHub 登录、用户账号、常驻房间、音源账号和已存储的媒体。</p>
<div className="admin-sign-in">
<form
className="admin-sign-in-card form-stack"
aria-labelledby="admin-sign-in-title"
onSubmit={(event) => {
event.preventDefault()
adminStore.signIn(token)
}}
>
<div>
<p className="admin-eyebrow">{brandName}</p>
<h1 id="admin-sign-in-title">后台</h1>
</div>
{signedIn && (
<button type="button" className="button-ghost" onClick={() => adminStore.signOut()}>
<LogOut size={16} aria-hidden="true" />
退出后台
</button>
)}
</div>
{signedIn
? (
<>
<SiteSettings />
<GithubApp />
<Accounts />
<PersistentRooms onEntered={onEntered} />
<SourceAccounts />
<StoredMedia />
</>
)
: <SignIn />}
<label>
管理 token
<input type="password" autoComplete="off" autoFocus required value={token} onChange={event => setToken(event.target.value)} />
</label>
<p className="text-ui-caption muted">管理 token 只保存在这个标签页中,关闭标签页后要重新输入。</p>
<button type="submit" className="button-primary" disabled={!token}>进入后台</button>
</form>
</div>
)
}
function Sections({ section, q, page, sort, onNavigate, onEntered }: AdminPageProps) {
const brandName = useStore(brandStore, s => s.name)
const credentials = useSourceCredentials()
const flagged = Object.values(credentials).some(credential => needsSignIn(credential.state))
const shown = section ?? 'site'
return (
<SectionLayout
title="后台"
eyebrow={brandName}
sections={sections.map(item => item.id === 'sources' ? { ...item, badge: flagged } : item)}
current={section}
defaultSection="site"
onSelect={id => onNavigate({ section: id })}
footer={(
<button type="button" className="section-nav-item" onClick={() => adminStore.signOut()}>
<LogOut size={18} strokeWidth={1.8} aria-hidden="true" />
退出后台
</button>
)}
>
{shown === 'site' && <SiteSettings />}
{shown === 'registration' && <Registration />}
{shown === 'users' && <Accounts />}
{shown === 'rooms' && <PersistentRooms q={q} page={page} onFilter={filter => onNavigate({ section: 'rooms', ...filter })} onEntered={onEntered} />}
{shown === 'sources' && <SourceAccounts credentials={credentials} />}
{shown === 'media' && <MediaPage q={q} sort={sort} page={page} onNavigate={view => onNavigate({ section: 'media', ...view })} />}
</SectionLayout>
)
}
export function AdminPage(props: AdminPageProps) {
const signedIn = useStore(adminStore, s => s.signedIn)
return signedIn ? <Sections {...props} /> : <SignIn />
}
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError } from '@/core/api'
import { notificationStore } from '@/shared/ui'
import { adminStore } from './admin-store'
class FakeStorage {
readonly #items = new Map<string, string>()
getItem(key: string): string | null {
return this.#items.get(key) ?? null
}
setItem(key: string, value: string): void {
this.#items.set(key, value)
}
removeItem(key: string): void {
this.#items.delete(key)
}
}
const messages = () => notificationStore.state.notifications.map(n => n.message)
beforeEach(() => {
vi.stubGlobal('sessionStorage', new FakeStorage())
for (const { id } of notificationStore.state.notifications)
notificationStore.dismiss(id)
adminStore.signIn('token')
})
describe('adminStore.attempt', () => {
it('成功时返回结果', async () => {
await expect(adminStore.attempt(async () => 42)).resolves.toBe(42)
})
it('失败时抛出给用户看的原因,不弹通知', async () => {
const failing = adminStore.attempt(() => Promise.reject(new ApiError('brandName:长度超过 20', 'INVALID_ARGUMENT', 400)), { INVALID_ARGUMENT: '品牌名太长了。' })
await expect(failing).rejects.toThrow('品牌名太长了。')
expect(messages()).toEqual([])
expect(adminStore.state.signedIn).toBe(true)
})
it('管理 token 被拒绝时提示并退出登录', async () => {
const rejected = new ApiError('管理 token 无效', 'UNAUTHORIZED', 401)
await expect(adminStore.attempt(() => Promise.reject(rejected))).rejects.toThrow('管理 token 无效')
expect(messages()).toEqual(['管理 token 无效'])
expect(adminStore.state.signedIn).toBe(false)
expect(sessionStorage.getItem('alisten.admin-token')).toBeNull()
})
it('已经退出登录时不再重复提示', async () => {
adminStore.signOut()
await expect(adminStore.attempt(() => Promise.reject(new ApiError('管理 token 无效', 'UNAUTHORIZED', 401)))).rejects.toThrow()
expect(messages()).toEqual([])
})
})
+29 -7
View File
@@ -4,6 +4,11 @@ import { errorMessage } from '@/core/errors'
import { Store } from '@/shared/lib/store'
import { notificationStore } from '@/shared/ui'
// 管理 token 无效、后台没有启用或网址不被服务器接受。
function rejectsToken(error: unknown): boolean {
return error instanceof ApiError && (error.code === 'UNAUTHORIZED' || error.code === 'FORBIDDEN')
}
class AdminStore extends Store<{ signedIn: boolean }> {
constructor() {
super({ signedIn: false })
@@ -23,22 +28,39 @@ class AdminStore extends Store<{ signedIn: boolean }> {
this.setState({ signedIn: false })
}
// 失败时显示错误并返回 null;管理 token 无效、后台没有启用或网址不被服务器接受时退出登录。
// 失败时显示错误并返回 null;管理 token 被拒绝时退出登录。
// 204 的接口成功时得到 undefined,调用方要与 null 比较,不能只看真假。
async call<T>(request: () => Promise<T>, texts?: ErrorTexts): Promise<T | null> {
try {
return await request()
}
catch (error) {
// 同时进行的请求会一起失败,退出登录后不再重复提示。
if (!this.state.signedIn)
return null
notificationStore.error(errorMessage(error, texts))
if (error instanceof ApiError && (error.code === 'UNAUTHORIZED' || error.code === 'FORBIDDEN'))
this.signOut()
this.#report(error, texts)
return null
}
}
// 失败时抛出 Error,message 是给用户看的原因,由调用方就地显示,不弹通知。
// 管理 token 被拒绝时例外:退出登录后这一页就不显示了,所以照常提示。
async attempt<T>(request: () => Promise<T>, texts?: ErrorTexts): Promise<T> {
try {
return await request()
}
catch (error) {
if (rejectsToken(error))
this.#report(error, texts)
throw new Error(errorMessage(error, texts))
}
}
#report(error: unknown, texts?: ErrorTexts): void {
// 同时进行的请求会一起失败,退出登录后不再重复提示。
if (!this.state.signedIn)
return
notificationStore.error(errorMessage(error, texts))
if (rejectsToken(error))
this.signOut()
}
}
export const adminStore = new AdminStore()
+111 -25
View File
@@ -1,36 +1,122 @@
.admin-page { container: admin / inline-size; max-width: 1040px; }
.admin-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.admin-sign-in { max-width: 420px; }
.admin-sign-in > .button-primary { align-self: flex-start; }
.admin-sign-in { display: grid; place-items: center; height: 100%; overflow-y: auto; padding: 32px 16px; background: var(--color-content); }
.admin-sign-in-card { --color-input: var(--color-input-on-card); width: min(400px, 100%); padding: 28px; border: 1px solid var(--color-border); border-radius: 16px; background: var(--color-card); }
.admin-sign-in-card h1 { font-size: var(--text-ui-xl); line-height: 1.25; }
.admin-sign-in-card > .button-primary { width: 100%; }
.admin-eyebrow { margin-bottom: 2px; overflow-wrap: anywhere; color: var(--color-brand); font-size: var(--text-ui-sm); font-weight: 500; }
.admin-section { container: admin / inline-size; }
.admin-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px 16px; margin-bottom: 20px; }
.admin-head-copy { flex: 1 1 14em; min-width: 0; }
.admin-head h2 { font-size: calc(var(--text-ui-lg) + 2px); font-weight: 700; line-height: 1.3; }
.admin-head-description { margin-top: 4px; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
.admin-head-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.admin-search { display: flex; flex: 0 1 auto; align-items: center; width: 240px; gap: 8px; min-width: 0; min-height: 36px; padding: 0 14px; border: 1px solid var(--color-input-border); border-radius: 999px; background: var(--color-input); color: var(--color-foreground-subtlest); }
.admin-search:focus-within { border-color: var(--color-input-border-focused); background: var(--color-input-focused); }
.admin-search > input { flex: 1; padding: 7px 0; border: 0; border-radius: 0; background: transparent; }
.admin-toolbar { display: flex; margin-bottom: 16px; }
.admin-toolbar > .admin-search { width: 360px; }
.admin-failed { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 14px 16px; color: var(--color-foreground-subtle); }
.admin-failed > p { flex: 1 1 12em; min-width: 0; }
.admin-hint { padding: 4px; color: var(--color-foreground-subtle); }
.admin-row-error { color: var(--color-destructive); font-size: var(--text-ui-caption); }
/* 中性底色不用 surface:浅色主题里它和 card 同色,放在卡片上会看不见。 */
.admin-status { display: inline-flex; flex-shrink: 0; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; background: var(--color-card-hover); color: var(--color-foreground-subtle); font-size: var(--text-ui-sm); font-weight: 500; white-space: nowrap; }
.admin-status[data-tone='success'] { background: var(--color-accent); color: var(--color-success); }
.admin-status[data-tone='danger'] { background: color-mix(in srgb, var(--color-destructive) 14%, transparent); color: var(--color-destructive); }
.admin-row-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.admin-code { min-width: 0; padding: 6px 10px; border-radius: 8px; background: var(--color-card-hover); color: var(--color-foreground); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--text-ui-sm); overflow-wrap: anywhere; }
.admin-secret { user-select: all; }
.admin-input { width: 280px; max-width: 100%; }
.admin-form-actions { justify-content: flex-end; }
.button-secondary.admin-danger { color: var(--color-destructive); }
.admin-user-avatar { width: 40px; }
.admin-notice { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin-top: 12px; padding: 12px 16px; border: 1px solid color-mix(in srgb, var(--color-brand) 30%, transparent); border-radius: 14px; background: var(--color-accent); }
.admin-notice > p { flex: 1 1 16em; min-width: 0; font-size: var(--text-ui-caption); }
.admin-table > ul { margin: 0; padding: 0; list-style: none; }
.admin-table-head, .admin-room { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1.2fr) 96px 36px; align-items: center; gap: 12px; padding: 10px 8px 10px 16px; }
.admin-table-head { padding-block: 10px 8px; color: var(--color-foreground-subtlest); font-size: var(--text-ui-sm); }
.admin-room { border-top: 1px solid var(--color-card-hover); }
.admin-room-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.admin-room-name > strong { font-weight: 500; }
.admin-room-name > span, .admin-room-slug { color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
.admin-room-access { justify-self: start; }
.admin-menu-button { color: var(--color-foreground-subtle); }
.admin-menu-button:hover:not(:disabled), .admin-menu-button[aria-expanded='true'] { color: var(--color-foreground); }
.admin-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.admin-card { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 16px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-card); }
.admin-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.admin-card-top > h3 { min-width: 0; font-size: var(--text-ui-base); font-weight: 600; }
.admin-card-detail { overflow-wrap: anywhere; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
.admin-card-detail.error-text { margin: 0; color: var(--color-destructive); }
.admin-card-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 10px; }
.admin-field { display: flex; flex-direction: column; gap: 6px; overflow-wrap: anywhere; }
.admin-field > .error-text { margin: 0; }
.admin-section + .admin-section { margin-top: 40px; }
.admin-list { list-style: none; margin: 0; padding: 0 20px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-surface); }
.admin-row { display: flex; align-items: center; gap: 8px; min-height: 68px; padding: 12px 0; }
.admin-row + .admin-row { border-top: 1px solid var(--color-border); }
.admin-row-copy { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
.admin-row-title { font-weight: 600; }
.admin-row-meta { display: flex; align-items: center; gap: 6px; min-width: 0; }
.admin-row-actions { display: flex; align-items: center; gap: 8px; }
.admin-brand-name, .admin-github-field { width: 240px; }
.admin-github-callback { overflow-wrap: anywhere; }
.admin-link { color: var(--color-foreground-subtle); }
.admin-link:hover { color: var(--color-brand); }
.admin-empty { padding: 8px 0; }
.admin-steps { display: flex; flex-direction: column; gap: 6px; margin: 0; padding-left: 1.25em; list-style: decimal; }
.admin-steps ul { display: flex; flex-direction: column; gap: 4px; margin: 4px 0 0; padding-left: 1.25em; list-style: disc; }
.admin-failed { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.admin-qr { display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; }
.admin-qr-image { width: 200px; height: 200px; padding: 8px; border-radius: 8px; background: #fff; image-rendering: pixelated; }
.admin-qr-switch { display: flex; gap: 4px; padding: 4px; border-radius: 999px; background: var(--color-input); }
.admin-qr-switch > button { min-height: 32px; padding: 6px 16px; border: 0; border-radius: 999px; background: transparent; color: var(--color-foreground-subtle); }
.admin-qr-switch > button:hover:not(:disabled) { color: var(--color-foreground); }
.admin-qr-switch > button[aria-pressed='true'], .admin-qr-switch > button[aria-pressed='true']:hover:not(:disabled) { background: var(--color-foreground); color: var(--color-popover); }
@media (max-width: 767px) {
.admin-list { padding: 0 16px; }
}
@container admin (max-width: 600px) {
.admin-row { flex-wrap: wrap; }
.admin-row-actions { flex-basis: 100%; }
.admin-brand-name, .admin-github-field { flex: 1; width: auto; }
.media-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 12px; }
.media-toolbar[data-hidden] { visibility: hidden; }
.media-toolbar > .admin-search { width: 280px; }
.media-sort { flex-shrink: 0; }
.media-sort > .select-trigger { min-height: 36px; padding: 6px 12px; border-color: transparent; border-radius: 10px; background: var(--color-card-hover); font-size: var(--text-ui-caption); }
.media-total { margin-left: auto; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* 操作条不占位置,盖在筛选上面:勾选之后表格不会移动,Shift 连选时不会点错行。滚动时停在顶部,删除和取消一直够得着。 */
.media-selection-anchor { position: sticky; top: 0; z-index: 3; height: 0; }
.media-selection { position: absolute; top: 0; left: 50%; display: flex; align-items: center; gap: 6px; width: max-content; max-width: 100%; padding: 2px 2px 2px 16px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-toast); box-shadow: 0 10px 30px var(--color-shadow); transform: translateX(-50%); animation: fade-in 120ms ease both; }
.media-selection-count { margin-right: 8px; font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; white-space: nowrap; }
.media-selection > button { min-height: 30px; padding: 4px 14px; border-radius: 999px; font-size: var(--text-ui-caption); }
/* 选中的行有底色,圆角要裁掉它。菜单在顶层显示,不会被裁。 */
.media-table { overflow: clip; transition: opacity 150ms; }
.media-table[aria-busy='true'] { opacity: .6; }
.media-table > ul { margin: 0; padding: 0; list-style: none; }
.media-head, .media-row { display: grid; grid-template-columns: 16px minmax(0, 2.6fr) minmax(0, .8fr) minmax(0, 1fr) minmax(0, 1fr) 36px; align-items: center; gap: 12px; padding: 8px 8px 8px 16px; }
.media-head { padding-block: 10px 8px; color: var(--color-foreground-subtlest); font-size: var(--text-ui-sm); }
.media-row { min-height: 60px; border-top: 1px solid var(--color-card-hover); }
.media-row[data-selected] { background: var(--color-accent); }
/* 整格都能点:复选框本身只有 16px。 */
.media-check { display: flex; align-items: center; align-self: stretch; margin: -8px -12px -8px -16px; padding: 0 12px 0 16px; cursor: pointer; }
.media-song { display: flex; align-items: center; gap: 10px; min-width: 0; }
.media-song > .artwork { width: 36px; border-radius: 6px; }
.media-song > .artwork > svg { width: 18px; }
.media-song-copy { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.media-song-copy > strong { font-weight: 500; }
.media-song-copy > span, .media-cell { color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
.media-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.media-narrow { display: none; }
@container admin (max-width: 560px) {
.admin-head-actions { flex-basis: 100%; }
.admin-head-actions > .admin-search, .admin-toolbar > .admin-search { flex: 1 1 auto; width: auto; }
.admin-input { width: 100%; }
.admin-cards { grid-template-columns: minmax(0, 1fr); }
.admin-table-head { display: none; }
.admin-room { grid-template: 'name access menu' auto 'slug access menu' auto / minmax(0, 1fr) auto 36px; row-gap: 2px; }
.admin-room:first-child { border-top: 0; }
.admin-room-name { grid-area: name; }
.admin-room-slug { grid-area: slug; }
.admin-room-access { grid-area: access; }
.admin-room > .admin-menu-button { grid-area: menu; }
.media-toolbar > .admin-search { flex: 1 1 100%; width: auto; }
/* 搜索框独占一行,操作条只盖住这一行:排序和总数照常显示,操作条下面不会空出一行。 */
.media-toolbar[data-hidden] > :not(.admin-search) { visibility: visible; }
.media-head, .media-row { grid-template-columns: 16px minmax(0, 1fr) 36px; }
.media-cell { display: none; }
/* 窄的时候封面让出位置:标题下面两行要放下大小、存储时间和最近播放。 */
.media-song > .artwork { display: none; }
.media-song-copy .media-narrow { display: inline; }
.media-song-copy > .media-narrow { display: block; }
}
+65 -74
View File
@@ -4,6 +4,7 @@ import { api } from '@/core/api'
import { ConfirmDialog, notificationStore, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
import { githubAppDraft } from './github-app-draft'
import { LoadFailed, StatusPill } from './section-head'
async function copyCallbackUrl(url: string): Promise<void> {
try {
@@ -17,8 +18,8 @@ async function copyCallbackUrl(url: string): Promise<void> {
}
export function GithubApp() {
// undefined 表示正在获取,null 表示获取失败。
const [app, setApp] = useState<AdminGetGithubCredentialResponse | null>()
// undefined 表示正在获取,Error 表示获取失败。
const [app, setApp] = useState<AdminGetGithubCredentialResponse | Error>()
const [attempt, setAttempt] = useState(0)
// null 表示沿用已保存的 Client ID。
const [clientId, setClientId] = useState<string | null>(null)
@@ -28,9 +29,12 @@ export function GithubApp() {
const [closing, setClosing] = useState(false)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminGetGithubCredential()).then((result) => {
adminStore.attempt(() => api.adminGetGithubCredential()).then((result) => {
if (active)
setApp(result)
}, (error: Error) => {
if (active)
setApp(error)
})
return () => {
active = false
@@ -42,9 +46,13 @@ export function GithubApp() {
setClientSecret('')
setAttempt(n => n + 1)
}
const draft = githubAppDraft(clientId ?? app?.clientId ?? '', clientSecret)
if (app instanceof Error)
return <LoadFailed message={`暂时无法获取 GitHub 登录的配置。${app.message}`} onRetry={refetch} />
if (app === undefined)
return <Skeleton rows={3} />
const draft = githubAppDraft(clientId ?? app.clientId ?? '', clientSecret)
const save = async () => {
if (draft === null || !app)
if (draft === null)
return
setBusy(true)
try {
@@ -59,74 +67,56 @@ export function GithubApp() {
}
}
return (
<section className="admin-section" aria-labelledby="admin-github-title">
<div className="section-heading">
<h2 id="admin-github-title" className="text-ui-lg">GitHub 登录</h2>
</div>
{app === null
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取 GitHub 登录的配置。</p>
<button type="button" className="button-secondary" onClick={refetch}>重试</button>
</div>
)
: app === undefined
? <Skeleton rows={3} />
: (
<form
className="admin-list"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">{app.clientId === null ? '未开启' : '已开启'}</span>
<span className="text-ui-sm muted">
{app.clientId === null
? '在 GitHub 的 Settings → Developer settings → OAuth Apps 新建一个 OAuth App,填好下面的 Client ID 和 Client Secret 就会开启。'
: '用户可以用 GitHub 登录、注册和绑定账号。'}
</span>
</div>
{app.clientId !== null && (
<div className="admin-row-actions">
<button type="button" className="button-secondary" onClick={() => setClosing(true)}>关闭 GitHub 登录</button>
</div>
)}
</div>
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">回调地址</span>
<code className="admin-github-callback text-ui-sm">{app.callbackUrl}</code>
<span className="text-ui-sm muted">OAuth App 的 Authorization callback URL 填这个地址。</span>
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" onClick={() => void copyCallbackUrl(app.callbackUrl)}>复制</button>
</div>
</div>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-github-client-id" className="admin-row-title text-ui-base">Client ID</label>
</div>
<div className="admin-row-actions">
<input id="admin-github-client-id" className="admin-github-field" autoComplete="off" spellCheck={false} value={clientId ?? app.clientId ?? ''} onChange={event => setClientId(event.target.value)} />
</div>
</div>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-github-client-secret" className="admin-row-title text-ui-base">Client Secret</label>
<span id="admin-github-client-secret-description" className="text-ui-sm muted">
{app.clientSecretSet ? '已设置。已保存的不会显示,保存时要和 Client ID 一起重新填写。' : '保存后不会再显示。'}
</span>
</div>
<div className="admin-row-actions">
<input id="admin-github-client-secret" className="admin-github-field" type="password" autoComplete="off" spellCheck={false} aria-describedby="admin-github-client-secret-description" placeholder={app.clientSecretSet ? '已设置' : undefined} value={clientSecret} onChange={event => setClientSecret(event.target.value)} />
<button type="submit" className="button-secondary" disabled={busy || draft === null}>{busy ? '正在保存…' : app.clientId === null ? '开启' : '保存'}</button>
</div>
</div>
</form>
)}
<>
<form
className="section-group"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">状态</span>
<span className="section-row-description">
{app.clientId === null
? '在 GitHub 的 Settings → Developer settings → OAuth Apps 新建一个 OAuth App,填好下面的 Client ID 和 Client Secret 就会开启。'
: '用户可以用 GitHub 登录、注册和绑定账号。'}
</span>
</div>
<StatusPill tone={app.clientId === null ? 'neutral' : 'success'}>{app.clientId === null ? '未开启' : '已开启'}</StatusPill>
</div>
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">回调地址</span>
<span className="section-row-description">OAuth App 的 Authorization callback URL 填这个地址。</span>
</div>
<div className="admin-row-actions">
<code className="admin-code">{app.callbackUrl}</code>
<button type="button" className="button-secondary" onClick={() => void copyCallbackUrl(app.callbackUrl)}>复制</button>
</div>
</div>
<div className="section-row">
<div className="section-row-copy">
<label htmlFor="admin-github-client-id" className="section-row-title">Client ID</label>
</div>
<input id="admin-github-client-id" className="admin-input" autoComplete="off" spellCheck={false} value={clientId ?? app.clientId ?? ''} onChange={event => setClientId(event.target.value)} />
</div>
<div className="section-row">
<div className="section-row-copy">
<label htmlFor="admin-github-client-secret" className="section-row-title">Client Secret</label>
<span id="admin-github-client-secret-description" className="section-row-description">
{app.clientSecretSet ? '已设置。已保存的不会显示,保存时要和 Client ID 一起重新填写。' : '保存后不会再显示。'}
</span>
</div>
<input id="admin-github-client-secret" className="admin-input" type="password" autoComplete="off" spellCheck={false} aria-describedby="admin-github-client-secret-description" placeholder={app.clientSecretSet ? '已设置' : undefined} value={clientSecret} onChange={event => setClientSecret(event.target.value)} />
</div>
<div className="section-row admin-form-actions">
{app.clientId !== null && <button type="button" className="button-secondary admin-danger" onClick={() => setClosing(true)}>关闭 GitHub 登录</button>}
<button type="submit" className="button-primary" disabled={busy || draft === null}>{busy ? '正在保存…' : app.clientId === null ? '开启' : '保存'}</button>
</div>
</form>
{/* 关闭之后重新读取配置,整个表单换成骨架屏:焦点落到分类标题。 */}
{closing && (
<ConfirmDialog
title="关闭 GitHub 登录"
@@ -140,10 +130,11 @@ export function GithubApp() {
notificationStore.success('已关闭 GitHub 登录')
}}
onClose={() => setClosing(false)}
returnFocus={() => document.getElementById('admin-registration-title')}
>
关闭后,不能再用 GitHub 登录、注册或绑定,已经绑定的 GitHub 保持绑定。只绑定了 GitHub 的账号,关闭之后就无法用 GitHub 登录,重新开启之前也无法在其他设备上登录。
</ConfirmDialog>
)}
</section>
</>
)
}
+1
View File
@@ -1,2 +1,3 @@
export { AdminPage } from './admin-page'
export type { AdminSection } from './admin-page'
export { adminStore } from './admin-store'
+300
View File
@@ -0,0 +1,300 @@
import type { AdminListMediaRequest, AdminRemoveMediaRequest } from '@/core/api'
import type { StoredMedia } from '@/core/realtime'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { notificationStore } from '@/shared/ui'
import { adminStore } from './admin-store'
import { fallbackPage, MediaStore } from './media-store'
class FakeStorage {
readonly #items = new Map<string, string>()
getItem(key: string): string | null {
return this.#items.get(key) ?? null
}
setItem(key: string, value: string): void {
this.#items.set(key, value)
}
removeItem(key: string): void {
this.#items.delete(key)
}
}
function media(n: number): StoredMedia {
return { key: `k${n}`, source: 'link', link: `https://example.com/${n}.mp3`, title: `歌 ${n}`, artist: '', coverUrl: '', size: n * 1000, storedAtMs: n, lastPlayedAtMs: null }
}
let library: StoredMedia[]
let playing: Set<string>
let reads: AdminListMediaRequest[]
let removals: string[][]
// holdReads 时读取的结果在请求到达时确定,等 release 之后才返回,模拟慢的读取。
let holdReads: boolean
let release: (() => void)[]
let failReads: boolean
let failRemovals: boolean
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status })
}
const unavailable = { error: { code: 'UNAVAILABLE', message: '服务暂时不可用,请稍后再试。' } }
async function list(body: AdminListMediaRequest): Promise<Response> {
reads.push(body)
const page = body.page ?? 1
const matched = library.filter(item => item.title.includes(body.q ?? ''))
const response = json(200, {
items: matched.slice((page - 1) * 50, page * 50),
total: matched.length,
totalBytes: matched.reduce((sum, item) => sum + item.size, 0),
page,
pageSize: 50,
})
if (holdReads)
await new Promise<void>(resolve => release.push(resolve))
return failReads ? json(503, unavailable) : response
}
function remove(body: AdminRemoveMediaRequest): Response {
const keys = body.media.map(ref => ref.key)
removals.push(keys)
const inUse = keys.filter(key => playing.has(key))
const deleted = keys.filter(key => !playing.has(key) && library.some(item => item.key === key))
// 中途出错时,出错之前删掉的不会恢复。
const done = failRemovals ? deleted.slice(0, -1) : deleted
library = library.filter(item => !done.includes(item.key))
return failRemovals ? json(503, unavailable) : json(200, { deleted, inUse })
}
const keys = (store: MediaStore) => store.state.list?.items.map(item => item.key)
const selected = (store: MediaStore) => [...store.state.selected].sort()
const messages = () => notificationStore.state.notifications.map(n => n.message)
// 等作废的读取走完:它不改变任何状态,没有可以等待的信号。
const drain = () => new Promise(resolve => setTimeout(resolve, 20))
function releaseAll(): void {
for (const resolve of release.splice(0))
resolve()
}
beforeEach(() => {
library = Array.from({ length: 55 }, (_, index) => media(index + 1))
playing = new Set()
reads = []
removals = []
holdReads = false
release = []
failReads = false
failRemovals = false
vi.stubGlobal('sessionStorage', new FakeStorage())
vi.stubGlobal('fetch', async (path: string, init: RequestInit) => {
const body: unknown = JSON.parse(init.body as string)
if (path === '/api/admin/media/queries')
return list(body as AdminListMediaRequest)
if (path === '/api/admin/media/removals')
return remove(body as AdminRemoveMediaRequest)
return json(404, { error: { code: 'NOT_FOUND', message: '接口不存在' } })
})
for (const { id } of notificationStore.state.notifications)
notificationStore.dismiss(id)
adminStore.signIn('token')
})
describe('mediaStore 的读取', () => {
it('按地址里的参数读取一页', async () => {
const store = new MediaStore()
store.show({ q: '歌 1', sort: 'size', page: 1 })
expect(store.state.loading).toBe(true)
await vi.waitFor(() => expect(store.state.loading).toBe(false))
expect(reads).toEqual([{ q: '歌 1', sort: 'size', page: 1 }])
expect(store.state.list).toMatchObject({ total: 11, page: 1, pageSize: 50 })
expect(store.state.error).toBeNull()
})
it('同一组参数只请求一次', async () => {
const store = new MediaStore()
store.show({ q: '', sort: 'stored', page: 1 })
store.show({ q: '', sort: 'stored', page: 1 })
await vi.waitFor(() => expect(store.state.loading).toBe(false))
store.show({ q: '', sort: 'stored', page: 1 })
expect(reads).toHaveLength(1)
store.show({ q: '', sort: 'stored', page: 2 })
store.show({ q: '', sort: 'stored', page: 2 })
await vi.waitFor(() => expect(store.state.loading).toBe(false))
expect(reads.map(read => read.page)).toEqual([1, 2])
})
it('参数变化时,先发出的读取晚回来也不覆盖新的一页', async () => {
const store = new MediaStore()
holdReads = true
store.show({ q: '', sort: 'stored', page: 1 })
store.show({ q: '', sort: 'stored', page: 2 })
await vi.waitFor(() => expect(release).toHaveLength(2))
const [first, second] = release.splice(0)
second!()
await vi.waitFor(() => expect(store.state.loading).toBe(false))
expect(keys(store)).toEqual(['k51', 'k52', 'k53', 'k54', 'k55'])
first!()
await drain()
expect(store.state.list?.page).toBe(2)
expect(store.state.loading).toBe(false)
})
it('新的一页读到之前保留上一页', async () => {
const store = new MediaStore()
store.show({ q: '', sort: 'stored', page: 1 })
await vi.waitFor(() => expect(store.state.loading).toBe(false))
holdReads = true
store.show({ q: '', sort: 'stored', page: 2 })
expect(store.state.loading).toBe(true)
expect(store.state.list?.page).toBe(1)
await vi.waitFor(() => expect(release).toHaveLength(1))
releaseAll()
await vi.waitFor(() => expect(store.state.loading).toBe(false))
expect(store.state.list?.page).toBe(2)
})
it('读取失败时就地给出原因,不弹通知;重试时即使参数没变也重新读取', async () => {
const store = new MediaStore()
failReads = true
store.show({ q: '', sort: 'stored', page: 1 })
await vi.waitFor(() => expect(store.state.error).toBe('服务暂时不可用,请稍后再试。'))
expect(store.state.loading).toBe(false)
expect(messages()).toEqual([])
failReads = false
store.retry()
expect(store.state.error).toBeNull()
await vi.waitFor(() => expect(store.state.loading).toBe(false))
expect(reads).toHaveLength(2)
expect(store.state.list?.items).toHaveLength(50)
})
})
describe('mediaStore 的选择', () => {
async function opened(page = 1): Promise<MediaStore> {
const store = new MediaStore()
store.show({ q: '', sort: 'stored', page })
await vi.waitFor(() => expect(store.state.loading).toBe(false))
return store
}
it('逐个切换,按住 Shift 选中上一次点的到这一项之间的全部', async () => {
const store = await opened()
store.toggle('k2', false)
store.toggle('k5', true)
expect(selected(store)).toEqual(['k2', 'k3', 'k4', 'k5'])
store.toggle('k3', false)
expect(selected(store)).toEqual(['k2', 'k4', 'k5'])
})
it('表头的复选框全选本页,再点一次全部取消', async () => {
const store = await opened(2)
store.toggle('k52', false)
store.toggleAll()
expect(selected(store)).toEqual(['k51', 'k52', 'k53', 'k54', 'k55'])
store.toggleAll()
expect(selected(store)).toEqual([])
})
it('换了参数就清空选择,Shift 也不再从上一页选起', async () => {
const store = await opened()
store.toggle('k1', false)
store.show({ q: '', sort: 'stored', page: 2 })
expect(selected(store)).toEqual([])
await vi.waitFor(() => expect(store.state.loading).toBe(false))
store.toggle('k53', true)
expect(selected(store)).toEqual(['k53'])
})
it('取消选择', async () => {
const store = await opened()
store.toggle('k1', false)
store.clearSelection()
expect(selected(store)).toEqual([])
})
})
describe('mediaStore 的删除', () => {
async function opened(page = 1): Promise<MediaStore> {
const store = new MediaStore()
store.show({ q: '', sort: 'stored', page })
await vi.waitFor(() => expect(store.state.loading).toBe(false))
return store
}
it('删除之后重新读取当前页,读完才返回', async () => {
const store = await opened()
const result = await store.remove(['k1', 'k2'])
expect(result).toEqual({ deleted: ['k1', 'k2'], inUse: [] })
expect(removals).toEqual([['k1', 'k2']])
expect(reads.map(read => read.page)).toEqual([1, 1])
expect(store.state.loading).toBe(false)
expect(keys(store)?.slice(0, 2)).toEqual(['k3', 'k4'])
expect(store.state.list?.total).toBe(53)
})
it('正在房间里播放的歌原样交给界面,它们留在这一页上,也仍然选中', async () => {
playing = new Set(['k2'])
const store = await opened()
store.toggle('k1', false)
store.toggle('k3', true)
const result = await store.remove([...store.state.selected])
expect(result).toEqual({ deleted: ['k1', 'k3'], inUse: ['k2'] })
expect(keys(store)?.slice(0, 2)).toEqual(['k2', 'k4'])
expect(selected(store)).toEqual(['k2'])
})
it('当前页删空、又不是第 1 页时,回到上一页', async () => {
const store = await opened(2)
await store.remove(['k51', 'k52', 'k53', 'k54', 'k55'])
expect(reads.map(read => read.page)).toEqual([2, 2])
expect(store.state.list?.items).toEqual([])
expect(fallbackPage(store.state.list!)).toBe(1)
})
it('中途出错时提示原因,也重新读取当前页:出错之前删掉的歌不再显示,选择里也去掉', async () => {
const store = await opened()
store.toggle('k1', false)
store.toggle('k3', true)
failRemovals = true
expect(await store.remove([...store.state.selected])).toBeNull()
expect(messages()).toEqual(['服务暂时不可用,请稍后再试。'])
expect(reads.map(read => read.page)).toEqual([1, 1])
expect(keys(store)?.slice(0, 2)).toEqual(['k3', 'k4'])
expect(store.state.list?.total).toBe(53)
expect(selected(store)).toEqual(['k3'])
})
it('删除之前发出、还没回来的读取作废', async () => {
const store = await opened()
holdReads = true
store.retry()
await vi.waitFor(() => expect(release).toHaveLength(1))
const stale = release.shift()!
const removing = store.remove(['k1'])
await vi.waitFor(() => expect(release).toHaveLength(1))
releaseAll()
await removing
stale()
await drain()
expect(keys(store)?.[0]).toBe('k2')
})
})
describe('fallbackPage', () => {
const page = (items: number, total: number, at: number) => ({ items: Array.from({ length: items }, (_, index) => media(index)), total, totalBytes: 0, page: at, pageSize: 50 })
it('这一页有内容,或者已经是第 1 页时不用换页', () => {
expect(fallbackPage(page(5, 55, 2))).toBeNull()
expect(fallbackPage(page(0, 0, 1))).toBeNull()
})
it('空页改去最后一页有内容的页,全都没有时去第 1 页', () => {
expect(fallbackPage(page(0, 100, 3))).toBe(2)
expect(fallbackPage(page(0, 101, 9))).toBe(3)
expect(fallbackPage(page(0, 0, 4))).toBe(1)
})
})
+103
View File
@@ -0,0 +1,103 @@
import type { AdminListMediaResponse, AdminRemoveMediaResponse } from '@/core/api'
import type { MediaSort } from '@/core/realtime'
import { api } from '@/core/api'
import { toggleSelection } from '@/shared/lib/range-selection'
import { Store } from '@/shared/lib/store'
import { adminStore } from './admin-store'
export interface MediaQuery {
q: string
sort: MediaSort
page: number
}
interface MediaState {
// 最近读到的一页。换了参数之后、新的一页读到之前仍是上一页,这时 loading 为 true。
list: AdminListMediaResponse | null
// 读到 list 的时间,用来显示“几天前播放”。
loadedAt: number
loading: boolean
// 最近一次读取失败的原因,给用户看。
error: string | null
// 只含这一页上的歌。
selected: ReadonlySet<string>
}
// 读到的这一页是空的、又不是第 1 页时(删空了最后一页,或者打开了页码超出的旧链接),返回最后一页有内容的页码。
export function fallbackPage(list: AdminListMediaResponse): number | null {
if (list.items.length || list.page <= 1)
return null
return Math.max(1, Math.ceil(list.total / list.pageSize))
}
// 每打开一次媒体页新建一个:离开再回来时重新读取。
export class MediaStore extends Store<MediaState> {
#query: MediaQuery | null = null
// 只有最后发起的读取能写入:换了参数、重试或删除之后,此前的读取作废。
#generation = 0
#anchor: string | null = null
constructor() {
super({ list: null, loadedAt: 0, loading: false, error: null, selected: new Set() })
}
// 地址里的参数变化时调用;参数和上一次相同时不重新读取。换了参数就清空选择。
show(query: MediaQuery): void {
const current = this.#query
if (current && current.q === query.q && current.sort === query.sort && current.page === query.page)
return
this.#query = query
this.clearSelection()
void this.#load()
}
retry(): void {
void this.#load()
}
toggle(key: string, shift: boolean): void {
const keys = this.state.list?.items.map(item => item.key) ?? []
const { selected, anchor } = toggleSelection(this.state.selected, keys, key, this.#anchor, shift)
this.#anchor = anchor
this.setState({ selected })
}
toggleAll(): void {
const items = this.state.list?.items ?? []
const all = items.every(item => this.state.selected.has(item.key))
this.#anchor = null
this.setState({ selected: new Set(all ? [] : items.map(item => item.key)) })
}
clearSelection(): void {
this.#anchor = null
this.setState({ selected: new Set() })
}
// 失败时原因已经通过通知显示,返回 null。无论成败都重新读取当前页,读完才返回:界面不会再显示已经删掉的歌,中途出错时出错之前删掉的也一样。
async remove(keys: string[]): Promise<AdminRemoveMediaResponse | null> {
const result = await adminStore.call(() => api.adminRemoveMedia({ media: keys.map(key => ({ key })) }))
await this.#load()
return result
}
async #load(): Promise<void> {
const { q, sort, page } = this.#query!
const generation = ++this.#generation
this.setState({ loading: true, error: null })
try {
const list = await adminStore.attempt(() => api.adminListMedia({ page, sort, q }))
if (generation !== this.#generation)
return
// 删除之后重新读到的这一页上,删掉的歌已经不在了,选择里也去掉。
const present = new Set(list.items.map(item => item.key))
if (this.#anchor !== null && !present.has(this.#anchor))
this.#anchor = null
this.setState({ list, loadedAt: Date.now(), loading: false, selected: new Set([...this.state.selected].filter(key => present.has(key))) })
}
catch (error) {
if (generation === this.#generation)
this.setState({ loading: false, error: (error as Error).message })
}
}
}
+107
View File
@@ -0,0 +1,107 @@
import type { StoredMedia } from '@/core/realtime'
import { Ellipsis, ExternalLink, Trash2 } from 'lucide-react'
import { bookTitle } from '@/shared/lib/book-title'
import { formatDateTime } from '@/shared/lib/time'
import { ActionMenu, Artwork } from '@/shared/ui'
import { formatPlayed, formatSize, formatStoredDate, mediaByline } from './media-view'
// 按住 Shift 点击会把页面上的文字一起选中,按下时就阻止。
function keepTextSelection(event: { shiftKey: boolean, preventDefault: () => void }): void {
if (event.shiftKey)
event.preventDefault()
}
function MediaRow({ item, now, selected, onToggle, onDelete }: {
item: StoredMedia
now: number
selected: boolean
onToggle: (key: string, shift: boolean) => void
onDelete: (item: StoredMedia) => void
}) {
const size = formatSize(item.size)
const stored = formatStoredDate(item.storedAtMs, now)
const played = formatPlayed(item.lastPlayedAtMs, now)
const playedLabel = item.lastPlayedAtMs === null ? played : `最近播放 ${played}`
return (
<li className="media-row" data-key={item.key} data-selected={selected || undefined}>
<label className="media-check" onMouseDown={keepTextSelection}>
<input
type="checkbox"
aria-label={`选择${bookTitle(item.title)}`}
checked={selected}
onChange={event => onToggle(item.key, event.nativeEvent instanceof MouseEvent && event.nativeEvent.shiftKey)}
/>
</label>
<div className="media-song">
<Artwork src={item.coverUrl} title={item.title} />
<div className="media-song-copy">
<strong className="truncate" title={item.title}>{item.title}</strong>
<span className="truncate">
{mediaByline(item)}
<span className="media-narrow">{` · ${size}`}</span>
</span>
<span className="media-narrow truncate">{`存储于 ${stored} · ${playedLabel}`}</span>
</div>
</div>
<span className="media-cell">{size}</span>
<span className="media-cell" title={formatDateTime(item.storedAtMs)}>
<span className="sr-only">存储于</span>
{stored}
</span>
<span className="media-cell" title={item.lastPlayedAtMs === null ? undefined : formatDateTime(item.lastPlayedAtMs)}>
{item.lastPlayedAtMs !== null && <span className="sr-only">最近播放</span>}
{played}
</span>
<ActionMenu
actions={[
{ icon: ExternalLink, label: '打开歌曲页面', run: () => window.open(item.link, '_blank', 'noopener,noreferrer') },
{ icon: Trash2, label: '删除', danger: true, run: () => onDelete(item) },
]}
menuLabel={`${bookTitle(item.title)}的操作`}
className="icon-button admin-menu-button"
label={`${bookTitle(item.title)}的更多操作`}
title="更多操作"
>
<Ellipsis size={20} strokeWidth={1.7} aria-hidden="true" />
</ActionMenu>
</li>
)
}
// busy 时这一页还是换参数之前的内容,不能操作。
export function MediaTable({ items, now, selected, busy, onToggle, onToggleAll, onDelete }: {
items: StoredMedia[]
now: number
selected: ReadonlySet<string>
busy: boolean
onToggle: (key: string, shift: boolean) => void
onToggleAll: () => void
onDelete: (item: StoredMedia) => void
}) {
const count = items.filter(item => selected.has(item.key)).length
return (
<div className="section-group media-table" aria-busy={busy} inert={busy}>
<div className="media-head">
<label className="media-check">
<input
type="checkbox"
aria-label="全选本页"
checked={count > 0 && count === items.length}
ref={(input) => {
if (input)
input.indeterminate = count > 0 && count < items.length
}}
onChange={onToggleAll}
/>
</label>
<span aria-hidden="true">歌曲</span>
<span className="media-cell" aria-hidden="true">大小</span>
<span className="media-cell" aria-hidden="true">存储于</span>
<span className="media-cell" aria-hidden="true">最近播放</span>
</div>
<ul>
{items.map(item => <MediaRow key={item.key} item={item} now={now} selected={selected.has(item.key)} onToggle={onToggle} onDelete={onDelete} />)}
</ul>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import type { StoredMedia } from '@/core/realtime'
import { describe, expect, it } from 'vitest'
import { deleteConfirmText, formatPlayed, formatSize, formatStoredDate, mediaByline, mediaOrigin, removalMessage, searchDraft } from './media-view'
const MiB = 1024 * 1024
function media(patch: Partial<StoredMedia>): StoredMedia {
return { key: 'k', source: 'link', link: 'https://example.com/a.mp3', title: '夜に駆ける', artist: '', coverUrl: '', size: 0, storedAtMs: 0, lastPlayedAtMs: null, ...patch }
}
describe('formatSize', () => {
it('不到 1 GiB 用 MiB,之后用 GiB,保留一位小数', () => {
expect(formatSize(0)).toBe('0.0 MiB')
expect(formatSize(8.94 * MiB)).toBe('8.9 MiB')
expect(formatSize(1023 * MiB)).toBe('1023.0 MiB')
expect(formatSize(1024 * MiB)).toBe('1.0 GiB')
expect(formatSize(9.6 * 1024 * MiB)).toBe('9.6 GiB')
})
})
describe('formatStoredDate', () => {
const now = new Date(2026, 10, 11, 20, 0).getTime()
it('今年的只写月日,往年的带上年份', () => {
expect(formatStoredDate(new Date(2026, 2, 2, 8, 30).getTime(), now)).toBe('3 月 2 日')
expect(formatStoredDate(new Date(2025, 11, 31, 23, 59).getTime(), now)).toBe('2025 年 12 月 31 日')
})
})
describe('formatPlayed', () => {
const now = new Date(2026, 10, 11, 20, 0).getTime()
const minute = 60 * 1000
const day = 24 * 60 * minute
it('没有播放过', () => {
expect(formatPlayed(null, now)).toBe('从未播放')
})
it('按经过的时间取最大的单位', () => {
expect(formatPlayed(now - 59 * 1000, now)).toBe('刚刚')
expect(formatPlayed(now - 5 * minute, now)).toBe('5 分钟前')
expect(formatPlayed(now - 3 * 60 * minute, now)).toBe('3 小时前')
expect(formatPlayed(now - day, now)).toBe('1 天前')
expect(formatPlayed(now - 29 * day, now)).toBe('29 天前')
expect(formatPlayed(now - 125 * day, now)).toBe('4 个月前')
expect(formatPlayed(now - 400 * day, now)).toBe('1 年前')
})
it('服务器的时钟比本机快时算作刚刚', () => {
expect(formatPlayed(now + 5 * minute, now)).toBe('刚刚')
})
})
describe('mediaOrigin', () => {
it('哔哩哔哩的歌写平台名,链接写网站的域名', () => {
expect(mediaOrigin(media({ source: 'bilibili', link: 'https://www.bilibili.com/video/BV1xx411c7mD' }))).toBe('哔哩哔哩')
expect(mediaOrigin(media({ source: 'link', link: 'https://music.example.com/a/b.mp3?x=1' }))).toBe('music.example.com')
})
it('解析不了的链接原样显示', () => {
expect(mediaOrigin(media({ source: 'link', link: 'not a url' }))).toBe('not a url')
})
})
describe('mediaByline', () => {
it('有歌手时写“歌手 · 来源”', () => {
expect(mediaByline(media({ artist: 'YOASOBI', source: 'link', link: 'https://music.example.com/a.mp3' }))).toBe('YOASOBI · music.example.com')
})
it('不知道歌手时只写来源', () => {
expect(mediaByline(media({ source: 'bilibili', link: 'https://www.bilibili.com/video/BV1xx411c7mD' }))).toBe('哔哩哔哩')
})
})
describe('removalMessage', () => {
it('全部删除', () => {
expect(removalMessage(3, 0)).toBe('已删除 3 首')
})
it('有正在播放的歌时补一句', () => {
expect(removalMessage(3, 1)).toBe('已删除 2 首;1 首正在房间里播放,没有删除')
expect(removalMessage(2, 2)).toBe('2 首正在房间里播放,没有删除')
})
})
describe('deleteConfirmText', () => {
it('一首写出歌名和大小', () => {
expect(deleteConfirmText([media({ size: 8.94 * MiB })])).toBe('删除《夜に駆ける》(8.9 MiB)的音频和封面后,再点这首歌会重新下载。')
})
it('多首写出数量和总大小', () => {
expect(deleteConfirmText([media({ size: 8.94 * MiB }), media({ key: 'k2', size: 7.4 * MiB }), media({ key: 'k3', size: 5.4 * MiB })]))
.toBe('删除选中的 3 首歌(共 21.7 MiB)的音频和封面后,再点这些歌会重新下载。')
})
})
describe('searchDraft', () => {
it('地址里的搜索词是输入框自己提交的值时,保留输入框里的原样输入,包括首尾的空白', () => {
expect(searchDraft('summer ', 'summer')).toBe('summer ')
expect(searchDraft(' lemon', 'lemon')).toBe(' lemon')
expect(searchDraft('summer', 'summer')).toBe('summer')
})
it('地址里的搜索词被别处改掉时(清除搜索、后退),输入框跟着改', () => {
expect(searchDraft('summer ', '')).toBe('')
expect(searchDraft('summer breeze', 'summer')).toBe('summer')
expect(searchDraft(' ', 'lemon')).toBe('lemon')
})
})
+80
View File
@@ -0,0 +1,80 @@
import type { StoredMedia } from '@/core/realtime'
import { bookTitle } from '@/shared/lib/book-title'
const MiB = 1024 * 1024
const GiB = 1024 * MiB
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
// 地址里的搜索词变了之后输入框显示什么:它就是输入框自己提交的值(去掉了首尾空白)时保留原样的输入,比如末尾刚打的空格;被别处改掉时(清除搜索、后退)换成地址里的。
export function searchDraft(draft: string, q: string): string {
return draft.trim() === q ? draft : q
}
export function formatSize(bytes: number): string {
return bytes < GiB ? `${(bytes / MiB).toFixed(1)} MiB` : `${(bytes / GiB).toFixed(1)} GiB`
}
export function formatStoredDate(ms: number, now: number): string {
const date = new Date(ms)
const monthDay = `${date.getMonth() + 1} 月 ${date.getDate()} 日`
return date.getFullYear() === new Date(now).getFullYear() ? monthDay : `${date.getFullYear()} 年 ${monthDay}`
}
export function formatPlayed(ms: number | null, now: number): string {
if (ms === null)
return '从未播放'
const elapsed = now - ms
if (elapsed < minute)
return '刚刚'
if (elapsed < hour)
return `${Math.floor(elapsed / minute)} 分钟前`
if (elapsed < day)
return `${Math.floor(elapsed / hour)} 小时前`
const days = Math.floor(elapsed / day)
if (days < 30)
return `${days} 天前`
if (days < 365)
return `${Math.floor(days / 30)} 个月前`
return `${Math.floor(days / 365)} 年前`
}
export function mediaOrigin(item: StoredMedia): string {
switch (item.source) {
case 'bilibili':
return '哔哩哔哩'
case 'netease':
return '网易云音乐'
case 'qq':
return 'QQ 音乐'
case 'link':
try {
return new URL(item.link).host
}
catch {
return item.link
}
}
}
export function mediaByline(item: StoredMedia): string {
const origin = mediaOrigin(item)
return item.artist ? `${item.artist} · ${origin}` : origin
}
// 不存在的歌(比如在别的标签页里已经删掉了)也算删除:对管理员来说结果一样。
export function removalMessage(requested: number, inUse: number): string {
const busy = `${inUse} 首正在房间里播放,没有删除`
if (inUse === requested)
return busy
const removed = `已删除 ${requested - inUse} 首`
return inUse ? `${removed};${busy}` : removed
}
export function deleteConfirmText(items: StoredMedia[]): string {
const size = formatSize(items.reduce((sum, item) => sum + item.size, 0))
return items.length === 1
? `删除${bookTitle(items[0]!.title)}(${size})的音频和封面后,再点这首歌会重新下载。`
: `删除选中的 ${items.length} 首歌(共 ${size})的音频和封面后,再点这些歌会重新下载。`
}
+126 -69
View File
@@ -1,101 +1,151 @@
import type { RoomListing } from '@/core/realtime'
import { KeyRound, LockKeyhole, LogIn, Pencil, Plus, Share2, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { DoorOpen, Ellipsis, KeyRound, LockKeyhole, LogIn, Pencil, Plus, Search, SearchX, Share2, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { api } from '@/core/api'
import { copyInviteLink, roomEntryStore } from '@/features/rooms'
import { ConfirmDialog, IconButton, Skeleton } from '@/shared/ui'
import { rowAfterRemoval } from '@/shared/lib/removal-focus'
import { ActionMenu, ConfirmDialog, EmptyState, Pagination, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
import { rowOrHeading } from './removal-focus'
import { RoomApiKeyDialog } from './room-api-key-dialog'
import { filterRooms } from './room-filter'
import { RoomFormDialog } from './room-form-dialog'
import { LoadFailed, SectionHead, StatusPill } from './section-head'
export function PersistentRooms({ onEntered }: { onEntered: (ref: string) => void }) {
function enterRoom(room: RoomListing, onEntered: (ref: string) => void): void {
const ref = room.slug ?? room.id
const entry = roomEntryStore.state
if (entry.phase !== 'joined' || entry.room?.id !== room.id)
void roomEntryStore.enter(ref, room)
onEntered(ref)
}
// q 和 page 来自地址:改搜索词时回到第 1 页。
export function PersistentRooms({ q, page, onFilter, onEntered }: {
q: string
page: number
onFilter: (filter: { q: string, page?: number }) => void
onEntered: (ref: string) => void
}) {
const [rooms, setRooms] = useState<RoomListing[] | null>(null)
const [failed, setFailed] = useState(false)
const [failure, setFailure] = useState<string | null>(null)
const [reload, setReload] = useState(0)
const [editing, setEditing] = useState<RoomListing | 'new' | null>(null)
const [deleting, setDeleting] = useState<RoomListing | null>(null)
const [keyOf, setKeyOf] = useState<RoomListing | null>(null)
// 删掉的房间之后焦点落到哪一行:null 表示落到标题。
const nextFocusRef = useRef<string | null>(null)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminListRooms()).then((result) => {
if (!active)
return
if (result)
adminStore.attempt(() => api.adminListRooms()).then((result) => {
if (active)
setRooms(result.items)
else
setFailed(true)
}, (error: Error) => {
if (active)
setFailure(error.message)
})
return () => {
active = false
}
}, [reload])
const refresh = () => {
setFailed(false)
setFailure(null)
setReload(n => n + 1)
}
const retry = () => {
setRooms(null)
refresh()
}
const shown = rooms && filterRooms(rooms, q, page)
// 删掉房间或者打开旧链接时,地址里的页码可能超出范围,改成实际显示的那一页。
const shownPage = shown?.page
useEffect(() => {
if (shownPage !== undefined && shownPage !== page)
onFilter({ q, page: shownPage })
}, [shownPage, page, q, onFilter])
return (
<section className="admin-section" aria-labelledby="admin-rooms-title">
<div className="section-heading">
<h2 id="admin-rooms-title" className="text-ui-lg">常驻房间</h2>
<button type="button" className="button-secondary" onClick={() => setEditing('new')}>
<SectionHead id="admin-rooms-title" title="常驻房间" description="常驻房间不会因为没人在线而关闭。">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input type="search" aria-label="搜索房间" placeholder="搜索房间" value={q} onChange={event => onFilter({ q: event.target.value })} />
</label>
<button type="button" className="button-primary" onClick={() => setEditing('new')}>
<Plus size={16} aria-hidden="true" />
新建常驻房间
新建房间
</button>
</div>
{failed
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取常驻房间。</p>
<button type="button" className="button-secondary" onClick={retry}>重试</button>
</div>
)
: !rooms
</SectionHead>
{failure !== null
? <LoadFailed message={`暂时无法获取常驻房间。${failure}`} onRetry={retry} />
: !shown
? <Skeleton rows={3} />
: !rooms.length
? <p className="admin-empty text-ui-caption muted">还没有常驻房间。</p>
: !shown.items.length
? q.trim()
? (
<EmptyState icon={SearchX} title="没有找到房间" action={<button type="button" className="button-secondary" onClick={() => onFilter({ q: '' })}>清除搜索</button>}>
换个名称或别名再找找。
</EmptyState>
)
: (
<EmptyState icon={DoorOpen} title="还没有常驻房间" action={<button type="button" className="button-secondary" onClick={() => setEditing('new')}>新建房间</button>} />
)
: (
<ul className="admin-list">
{rooms.map(room => (
<li className="admin-row" key={room.id}>
<div className="admin-row-copy">
<strong className="text-ui-base truncate">{room.name}</strong>
<span className="admin-row-meta text-ui-sm muted">
<span className="truncate">{room.slug ? `/r/${room.slug}` : '没有别名'}</span>
{room.hasPassword && (
<>
<span aria-hidden="true">·</span>
<LockKeyhole size={12} aria-hidden="true" />
有密码
</>
)}
<span aria-hidden="true">·</span>
{`${room.onlineCount} 人在线`}
</span>
</div>
<div className="admin-row-actions">
<IconButton
icon={LogIn}
label={`打开「${room.name}」`}
onClick={() => {
const ref = room.slug ?? room.id
const entry = roomEntryStore.state
if (entry.phase !== 'joined' || entry.room?.id !== room.id)
void roomEntryStore.enter(ref, room)
onEntered(ref)
}}
/>
<IconButton icon={Share2} label={`复制「${room.name}」的邀请链接`} onClick={() => void copyInviteLink(room)} />
<IconButton icon={KeyRound} label={`「${room.name}」的 API Key`} onClick={() => setKeyOf(room)} />
<IconButton icon={Pencil} label={`编辑「${room.name}」`} onClick={() => setEditing(room)} />
<IconButton icon={Trash2} label={`删除「${room.name}」`} onClick={() => setDeleting(room)} />
</div>
</li>
))}
</ul>
<>
<div className="section-group admin-table">
<div className="admin-table-head" aria-hidden="true">
<span>房间</span>
<span>别名</span>
<span>访问</span>
</div>
<ul>
{shown.items.map(room => (
<li className="admin-room" key={room.id} data-key={room.id}>
<div className="admin-room-name">
<strong className="truncate">{room.name}</strong>
<span>{room.onlineCount ? `${room.onlineCount} 人在线` : '无人在线'}</span>
</div>
<span className="admin-room-slug truncate">
{room.slug
? `/r/${room.slug}`
: (
<>
<span aria-hidden="true">—</span>
<span className="sr-only">没有别名</span>
</>
)}
</span>
<span className="admin-room-access">
{room.hasPassword
? (
<StatusPill tone="neutral">
<LockKeyhole size={12} aria-hidden="true" />
<span className="sr-only">需要</span>
密码
</StatusPill>
)
: <StatusPill tone="neutral">公开</StatusPill>}
</span>
<ActionMenu
actions={[
{ icon: LogIn, label: '进入房间', run: () => enterRoom(room, onEntered) },
{ icon: Share2, label: '复制邀请链接', run: () => void copyInviteLink(room) },
{ icon: Pencil, label: '编辑', run: () => setEditing(room) },
{ icon: KeyRound, label: 'API Key', run: () => setKeyOf(room) },
{ icon: Trash2, label: '删除', danger: true, run: () => setDeleting(room) },
]}
menuLabel={`「${room.name}」的操作`}
className="icon-button admin-menu-button"
label={`「${room.name}」的更多操作`}
title="更多操作"
>
<Ellipsis size={20} strokeWidth={1.7} aria-hidden="true" />
</ActionMenu>
</li>
))}
</ul>
</div>
<Pagination page={shown.page} totalPages={shown.totalPages} onPage={next => onFilter({ q, page: next })} />
</>
)}
{editing && (
<RoomFormDialog
@@ -114,12 +164,19 @@ export function PersistentRooms({ onEntered }: { onEntered: (ref: string) => voi
confirmLabel="删除房间"
danger
onConfirm={async () => {
if (await adminStore.call(() => api.adminDeleteRoom(deleting.id)) !== null) {
setDeleting(null)
refresh()
}
if (await adminStore.call(() => api.adminDeleteRoom(deleting.id)) === null)
return
// 先在本地去掉这个房间,对话框关闭时那一行已经不在了,焦点才能落到下一行;之后再重新读取。
const remaining = rooms?.filter(room => room.id !== deleting.id) ?? []
const next = filterRooms(remaining, q, page)
// 删空了这一页时显示的是上一页,那里没有“下一行”。
nextFocusRef.current = rowAfterRemoval(shown?.items.map(room => room.id) ?? [], next.page === shown?.page ? next.items.map(room => room.id) : [], [deleting.id])
setRooms(remaining)
setDeleting(null)
refresh()
}}
onClose={() => setDeleting(null)}
returnFocus={() => rowOrHeading('admin-rooms-title', nextFocusRef.current)}
>
{`删除「${deleting.name}」后,房间里的所有人都会断开连接。`}
</ConfirmDialog>
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useId, useState } from 'react'
import { api } from '@/core/api'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { SaveStatus, Skeleton, Switch } from '@/shared/ui'
import { adminStore } from './admin-store'
import { GithubApp } from './github-app'
import { LoadFailed, SectionHead } from './section-head'
// 保存期间不禁用开关:禁用会让它失去焦点,键盘用户就找不到它了。
function OpenRegistrationRow({ open, onSaved }: { open: boolean, onSaved: (open: boolean) => void }) {
const switchId = useId()
const descriptionId = useId()
const errorId = useId()
const setting = useSettingSave(open, async (next: boolean) => {
await adminStore.attempt(() => next ? api.adminOpenRegistration() : api.adminCloseRegistration())
onSaved(next)
})
const { error } = setting.state
return (
<div className="section-group">
<div className="section-row">
<div className="section-row-copy">
<label htmlFor={switchId} className="section-row-title">开放注册</label>
<span id={descriptionId} className="section-row-description">关闭后不能注册新账号,已有账号照常登录,游客照常使用。</span>
{error && <span id={errorId} className="admin-row-error" role="alert">{error}</span>}
</div>
<div className="admin-row-actions">
<SaveStatus status={setting.state.status} version={setting.savedVersion} />
<Switch id={switchId} checked={setting.value} aria-describedby={error ? `${descriptionId} ${errorId}` : descriptionId} onChange={setting.change} />
</div>
</div>
</div>
)
}
function OpenRegistration() {
// undefined 表示正在获取,Error 表示获取失败。
const [open, setOpen] = useState<boolean | Error>()
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let active = true
adminStore.attempt(() => api.getSite()).then((site) => {
if (active)
setOpen(site.registrationOpen)
}, (error: Error) => {
if (active)
setOpen(error)
})
return () => {
active = false
}
}, [attempt])
if (open instanceof Error) {
return (
<LoadFailed
message={`暂时无法获取注册状态。${open.message}`}
onRetry={() => {
setOpen(undefined)
setAttempt(n => n + 1)
}}
/>
)
}
if (open === undefined)
return <Skeleton rows={1} />
return <OpenRegistrationRow open={open} onSaved={setOpen} />
}
export function Registration() {
return (
<section className="admin-section" aria-labelledby="admin-registration-title">
<SectionHead id="admin-registration-title" title="登录与注册" />
<OpenRegistration />
<h3 className="section-group-label">GitHub 登录</h3>
<GithubApp />
</section>
)
}
+6
View File
@@ -0,0 +1,6 @@
// 行是带 data-key 的 li,焦点落到它的第一个控件上;没有这一行时落到分类标题。
export function rowOrHeading(headingId: string, key: string | null): HTMLElement | null {
const heading = document.getElementById(headingId)
const row = key === null ? null : heading?.closest('section')?.querySelector<HTMLElement>(`li[data-key="${CSS.escape(key)}"] :is(input, button)`)
return row ?? heading
}
@@ -0,0 +1,46 @@
import type { RoomListing } from '@/core/realtime'
import { describe, expect, it } from 'vitest'
import { filterRooms } from './room-filter'
function room(id: string, name: string, slug: string | null = null): RoomListing {
return { id, name, slug, description: '', hasPassword: false, kind: 'persistent', onlineCount: 0 }
}
function rooms(count: number): RoomListing[] {
return Array.from({ length: count }, (_, index) => room(String(index + 1), `房间 ${index + 1}`))
}
const ids = (list: RoomListing[]) => list.map(item => item.id)
describe('filterRooms', () => {
const sample = [room('a', '大猫听歌房', 'livehouse'), room('b', 'MyTestRoom'), room('c', '深夜电台', 'Night')]
it('空查询返回全部', () => {
expect(filterRooms(sample, '', 1)).toEqual({ items: sample, totalPages: 1, page: 1 })
expect(ids(filterRooms(sample, ' ', 1).items)).toEqual(['a', 'b', 'c'])
})
it('按名称或别名匹配,不区分大小写', () => {
expect(ids(filterRooms(sample, 'mytest', 1).items)).toEqual(['b'])
expect(ids(filterRooms(sample, 'LIVE', 1).items)).toEqual(['a'])
expect(ids(filterRooms(sample, 'night', 1).items)).toEqual(['c'])
expect(ids(filterRooms(sample, '电台', 1).items)).toEqual(['c'])
expect(filterRooms(sample, 'jazz', 1)).toEqual({ items: [], totalPages: 1, page: 1 })
})
it('每页 20 条', () => {
const all = rooms(45)
const first = filterRooms(all, '', 1)
expect(first.totalPages).toBe(3)
expect(ids(first.items)).toEqual(ids(all.slice(0, 20)))
expect(ids(filterRooms(all, '', 3).items)).toEqual(['41', '42', '43', '44', '45'])
})
it('页码超出时退到最后一页', () => {
const result = filterRooms(rooms(45), '', 9)
expect(result.page).toBe(3)
expect(result.items).toHaveLength(5)
expect(filterRooms(rooms(45), '房间 4', 2)).toMatchObject({ totalPages: 1, page: 1 })
expect(filterRooms([], '', 2)).toEqual({ items: [], totalPages: 1, page: 1 })
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { RoomListing } from '@/core/realtime'
const roomsPerPage = 20
// 常驻房间的数量有限,一次取回全部,在前端搜索和分页。
export function filterRooms(rooms: RoomListing[], q: string, page: number): { items: RoomListing[], totalPages: number, page: number } {
const needle = q.trim().toLowerCase()
const matched = needle
? rooms.filter(room => room.name.toLowerCase().includes(needle) || room.slug?.toLowerCase().includes(needle))
: rooms
const totalPages = Math.max(1, Math.ceil(matched.length / roomsPerPage))
const current = Math.min(page, totalPages)
return { items: matched.slice((current - 1) * roomsPerPage, current * roomsPerPage), totalPages, page: current }
}
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { CloudOff } from 'lucide-react'
// 分类的标题要和内容一起立即渲染,加载中也不例外:从分类栏切换过来之后,焦点要落在它上面。
export function SectionHead({ id, title, description, children }: { id: string, title: string, description?: string, children?: ReactNode }) {
return (
<header className="admin-head">
<div className="admin-head-copy">
<h2 id={id} tabIndex={-1}>{title}</h2>
{description && <p className="admin-head-description">{description}</p>}
</div>
{children && <div className="admin-head-actions">{children}</div>}
</header>
)
}
export function LoadFailed({ message, onRetry }: { message: string, onRetry: () => void }) {
return (
<div className="section-group admin-failed" role="alert">
<CloudOff size={20} strokeWidth={1.7} aria-hidden="true" />
<p>{message}</p>
<button type="button" className="button-secondary" onClick={onRetry}>重试</button>
</div>
)
}
export type StatusTone = 'success' | 'danger' | 'neutral'
export function StatusPill({ tone, children }: { tone: StatusTone, children: ReactNode }) {
return <span className="admin-status" data-tone={tone}>{children}</span>
}
+26 -118
View File
@@ -1,150 +1,58 @@
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { brandStore } from '@/core/brand'
import { errorMessage } from '@/core/errors'
import { useStore } from '@/shared/lib/use-store'
import { notificationStore, Skeleton } from '@/shared/ui'
import { InlineTextField, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
import { LoadFailed, SectionHead } from './section-head'
async function saveBrandName(name: string): Promise<void> {
const saved = await adminStore.attempt(() => api.adminUpdateSite({ brandName: name }), { INVALID_ARGUMENT: '品牌名要有 1 到 20 个字,不能含换行、制表符等特殊字符。' })
// 显示服务端存下的名字:浏览器和服务端去掉首尾空白的规则不完全相同。
brandStore.setName(saved.brandName)
}
export function SiteSettings() {
const brandName = useStore(brandStore, s => s.name)
const [status, setStatus] = useState<'loading' | 'failed' | 'loaded'>('loading')
// undefined 表示正在获取,Error 表示获取失败。
const [status, setStatus] = useState<'loaded' | Error>()
const [attempt, setAttempt] = useState(0)
const [draft, setDraft] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
brandStore.load().then(() => {
if (active)
setStatus('loaded')
}, () => {
}, (error: unknown) => {
if (active)
setStatus('failed')
setStatus(new Error(errorMessage(error)))
})
return () => {
active = false
}
}, [attempt])
const retry = () => {
setStatus('loading')
setStatus(undefined)
setAttempt(n => n + 1)
}
const name = draft ?? brandName
const save = async () => {
setBusy(true)
try {
const saved = await adminStore.call(() => api.adminUpdateSite({ brandName: name }), { INVALID_ARGUMENT: '品牌名要有 1 到 20 个字,不能含换行、制表符等特殊字符。' })
if (saved === null)
return
// 显示服务端存下的名字:浏览器和服务端去掉首尾空白的规则不完全相同。
brandStore.setName(saved.brandName)
setDraft(null)
notificationStore.success('已保存品牌名')
}
finally {
setBusy(false)
}
}
return (
<section className="admin-section" aria-labelledby="admin-site-title">
<div className="section-heading">
<h2 id="admin-site-title" className="text-ui-lg">站点</h2>
</div>
{status === 'failed'
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取品牌名。</p>
<button type="button" className="button-secondary" onClick={retry}>重试</button>
</div>
)
: status === 'loading'
? <Skeleton rows={1} />
<SectionHead id="admin-site-title" title="站点" />
{status === undefined
? <Skeleton rows={1} />
: status instanceof Error
? <LoadFailed message={`暂时无法获取品牌名。${status.message}`} onRetry={retry} />
: (
<form
className="admin-list"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-brand-name" className="admin-row-title text-ui-base">品牌名</label>
<span id="admin-brand-name-description" className="text-ui-sm muted">显示在发现房间、后台页面的顶部和浏览器标签页上,最多 20 个字。已经打开的页面刷新后才会更新。</span>
</div>
<div className="admin-row-actions">
<input id="admin-brand-name" className="admin-brand-name" aria-describedby="admin-brand-name-description" autoComplete="off" value={name} onChange={event => setDraft(event.target.value)} />
<button type="submit" className="button-secondary" disabled={busy || name.trim() === ''}>{busy ? '正在保存…' : '保存'}</button>
<div className="section-group">
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">品牌名</span>
<span className="section-row-description">显示在发现房间、后台页面的顶部和浏览器标签页上,最多 20 个字。已经打开的页面刷新后才会更新。</span>
</div>
<InlineTextField label="品牌名" value={brandName} onSave={saveBrandName} />
</div>
<RegistrationSetting />
</form>
</div>
)}
</section>
)
}
function RegistrationSetting() {
const [open, setOpen] = useState<boolean | null>(null)
const [failed, setFailed] = useState(false)
const [attempt, setAttempt] = useState(0)
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
api.getSite().then((site) => {
if (active)
setOpen(site.registrationOpen)
}, () => {
if (active)
setFailed(true)
})
return () => {
active = false
}
}, [attempt])
const toggle = async () => {
if (open === null)
return
setBusy(true)
try {
const done = await adminStore.call(() => open ? api.adminCloseRegistration() : api.adminOpenRegistration())
if (done === null)
return
setOpen(!open)
notificationStore.success(open ? '已关闭注册' : '已开放注册')
}
finally {
setBusy(false)
}
}
const description = failed
? '暂时无法获取注册状态。'
: open === null
? '正在获取注册状态…'
: open
? '现在任何人都可以注册账号。关闭后不能再注册新账号,已有账号照常登录,游客照常使用。'
: '现在不能注册新账号。已有账号照常登录,游客照常使用。'
return (
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">注册</span>
<span className="text-ui-sm muted">{description}</span>
</div>
<div className="admin-row-actions">
{failed
? (
<button
type="button"
className="button-secondary"
onClick={() => {
setFailed(false)
setAttempt(n => n + 1)
}}
>
重试
</button>
)
: <button type="button" className="button-secondary" disabled={open === null || busy} onClick={() => void toggle()}>{open === false ? '开放注册' : '关闭注册'}</button>}
</div>
</div>
)
}
@@ -1,28 +1,29 @@
import type { AdminGetNeteaseCredentialResponse, AdminGetQQCredentialResponse } from '@/core/api'
import type { CredentialState } from './source-account-view'
import { describe, expect, it } from 'vitest'
import { sourceAccountView } from './source-account-view'
import { needsSignIn, sourceAccountView } from './source-account-view'
type State = AdminGetNeteaseCredentialResponse | AdminGetQQCredentialResponse | null | undefined
const cases: { name: string, state: State, want: ReturnType<typeof sourceAccountView> }[] = [
{ name: '正在查询', state: undefined, want: { status: '正在查询…', details: [] } },
{ name: '查询失败', state: null, want: { status: '查询失败', details: [] } },
{ name: '没有保存凭据', state: { set: false, account: null }, want: { status: '未登录', details: [] } },
{ name: '保存凭据但没有账号资料', state: { set: true, account: null }, want: { status: '已保存登录信息,未识别到账号', details: [] } },
const cases: { name: string, state: CredentialState, want: ReturnType<typeof sourceAccountView>, attention: boolean }[] = [
{ name: '正在查询', state: undefined, want: { status: '正在查询', tone: 'neutral', detail: '' }, attention: false },
{ name: '查询失败时说明原因', state: new Error('网络不通'), want: { status: '查询失败', tone: 'neutral', detail: '网络不通' }, attention: false },
{ name: '没有保存凭据', state: { set: false, account: null }, want: { status: '未登录', tone: 'danger', detail: '' }, attention: true },
{ name: '保存凭据但没有账号资料', state: { set: true, account: null }, want: { status: '未识别账号', tone: 'danger', detail: '已保存登录信息,但没有识别出账号。' }, attention: true },
{
name: '上游已验证的会员账号',
state: { set: true, account: { id: '123', name: '测试账号', verified: true, vip: true } },
want: { status: '已登录', details: ['昵称:测试账号', '账号 ID:123', '会员状态:会员'] },
want: { status: '已登录', tone: 'success', detail: '测试账号 · 会员' },
attention: false,
},
{
name: '非会员和未知昵称',
state: { set: true, account: { id: '123', name: null, verified: true, vip: false } },
want: { status: '已登录', details: ['账号 ID:123', '会员状态:非会员'] },
want: { status: '已登录', tone: 'success', detail: 'ID 123 · 非会员' },
attention: false,
},
{
name: '仅从本地凭据识别的账号',
state: { set: true, account: { id: '456', name: null, verified: false, vip: null } },
want: { status: '已保存登录信息,未经 QQ 音乐核实', details: ['账号 ID:456'] },
want: { status: '未经核实', tone: 'danger', detail: 'ID 456 · 已保存登录信息,未经 QQ 音乐核实' },
attention: true,
},
]
@@ -33,3 +34,12 @@ describe('sourceAccountView', () => {
})
}
})
// 后台分类栏的“音源”是否显示红点:查询中和查询失败时不知道状态,不显示。
describe('needsSignIn', () => {
for (const tc of cases) {
it(tc.name, () => {
expect(needsSignIn(tc.state)).toBe(tc.attention)
})
}
})
+18 -16
View File
@@ -1,31 +1,33 @@
import type { StatusTone } from './section-head'
import type { AdminGetBilibiliCredentialResponse, AdminGetNeteaseCredentialResponse, AdminGetQQCredentialResponse } from '@/core/api'
export type CredentialState = AdminGetNeteaseCredentialResponse | AdminGetQQCredentialResponse | AdminGetBilibiliCredentialResponse | null | undefined
// undefined 表示正在查询,Error 表示查询失败,message 是给用户看的原因。
export type CredentialState = AdminGetNeteaseCredentialResponse | AdminGetQQCredentialResponse | AdminGetBilibiliCredentialResponse | Error | undefined
// 接在中文后面的平台名以英文开头时,前面留一个空格,如「已登录 QQ 音乐」。
export function afterChinese(platform: string): string {
return /^[a-z0-9]/i.test(platform) ? ` ${platform}` : platform
}
export function sourceAccountView(state: CredentialState, platform: string): { status: string, details: string[] } {
export function needsSignIn(state: CredentialState): boolean {
return state !== undefined && !(state instanceof Error) && !state.account?.verified
}
export function sourceAccountView(state: CredentialState, platform: string): { status: string, tone: StatusTone, detail: string } {
if (state === undefined)
return { status: '正在查询…', details: [] }
if (state === null)
return { status: '查询失败', details: [] }
return { status: '正在查询', tone: 'neutral', detail: '' }
if (state instanceof Error)
return { status: '查询失败', tone: 'neutral', detail: state.message }
if (!state.set)
return { status: '未登录', details: [] }
return { status: '未登录', tone: 'danger', detail: '' }
if (state.account === null)
return { status: '已保存登录信息,未识别到账号', details: [] }
return { status: '未识别账号', tone: 'danger', detail: '已保存登录信息,但没有识别出账号。' }
const { account } = state
const details: string[] = []
if (account.name !== null)
details.push(`昵称:${account.name}`)
details.push(`账号 ID:${account.id}`)
const details = [account.name ?? `ID ${account.id}`]
if (account.vip !== null)
details.push(`会员状态:${account.vip ? '会员' : '非会员'}`)
return {
status: account.verified ? '已登录' : `已保存登录信息,未经${afterChinese(platform)}核实`,
details,
}
details.push(account.vip ? '会员' : '非会员')
if (!account.verified)
details.push(`已保存登录信息,未经${afterChinese(platform)}核实`)
return { status: account.verified ? '已登录' : '未经核实', tone: account.verified ? 'success' : 'danger', detail: details.join(' · ') }
}
+24 -68
View File
@@ -1,68 +1,27 @@
import type { QrLoginTarget } from './qr-login-dialog'
import type { CredentialState } from './source-account-view'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import type { Credential, Source, SourceId } from './source-credentials'
import { useId, useState } from 'react'
import { notificationStore } from '@/shared/ui'
import { adminStore } from './admin-store'
import { QrLoginDialog } from './qr-login-dialog'
import { SectionHead, StatusPill } from './section-head'
import { afterChinese, sourceAccountView } from './source-account-view'
import { sources } from './source-credentials'
import { YouTubeCookies } from './youtube-cookies'
interface Source {
name: string
credential: () => Promise<NonNullable<CredentialState>>
targets: QrLoginTarget[]
}
const sources: Source[] = [
{
name: 'QQ 音乐',
credential: () => api.adminGetQqCredential(),
targets: [
{ label: 'QQ 扫码', start: () => api.adminStartQqLogin({ type: 'qq' }), check: key => api.adminCheckQqLogin({ type: 'qq', key }) },
{ label: '微信扫码', start: () => api.adminStartQqLogin({ type: 'wx' }), check: key => api.adminCheckQqLogin({ type: 'wx', key }) },
],
},
{
name: '网易云音乐',
credential: () => api.adminGetNeteaseCredential(),
targets: [
{ label: '网易云音乐扫码', start: () => api.adminStartNeteaseLogin(), check: key => api.adminCheckNeteaseLogin({ key }) },
],
},
{
name: '哔哩哔哩',
credential: () => api.adminGetBilibiliCredential(),
targets: [
{ label: '哔哩哔哩扫码', start: () => api.adminStartBilibiliLogin(), check: key => api.adminCheckBilibiliLogin({ key }) },
],
},
]
function SourceAccount({ source }: { source: Source }) {
const [credential, setCredential] = useState<CredentialState>()
const [attempt, setAttempt] = useState(0)
function SourceCard({ source, credential: { state, reload } }: { source: Source, credential: Credential }) {
const titleId = useId()
const [signingIn, setSigningIn] = useState(false)
useEffect(() => {
let active = true
void adminStore.call(source.credential).then((result) => {
if (active)
setCredential(result)
})
return () => {
active = false
}
}, [source, attempt])
const view = sourceAccountView(credential, source.name)
const view = sourceAccountView(state, source.name)
return (
<li className="admin-row">
<div className="admin-row-copy">
<strong className="text-ui-base">{source.name}</strong>
<span className="text-ui-sm muted" role="status">{view.status}</span>
{view.details.map(detail => <span key={detail} className="text-ui-sm muted">{detail}</span>)}
<article className="admin-card" aria-labelledby={titleId}>
<div className="admin-card-top">
<h3 id={titleId}>{source.name}</h3>
<StatusPill tone={view.tone}>{view.status}</StatusPill>
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" disabled={credential === undefined} onClick={() => setSigningIn(true)}>{credential?.set ? '重新登录' : '扫码登录'}</button>
{view.detail && <p className="admin-card-detail">{view.detail}</p>}
<div className="admin-card-actions">
{state instanceof Error
? <button type="button" className="button-secondary" onClick={reload}>重试</button>
: <button type="button" className={state?.set ? 'button-secondary' : 'button-primary'} disabled={state === undefined} onClick={() => setSigningIn(true)}>{state?.set ? '重新登录' : '扫码登录'}</button>}
</div>
{signingIn && (
<QrLoginDialog
@@ -71,26 +30,23 @@ function SourceAccount({ source }: { source: Source }) {
onSuccess={() => {
setSigningIn(false)
notificationStore.success(`已登录${afterChinese(source.name)}`)
setCredential(undefined)
setAttempt(n => n + 1)
reload()
}}
onClose={() => setSigningIn(false)}
/>
)}
</li>
</article>
)
}
export function SourceAccounts() {
export function SourceAccounts({ credentials }: { credentials: Record<SourceId, Credential> }) {
return (
<section className="admin-section" aria-labelledby="admin-accounts-title">
<div className="section-heading">
<h2 id="admin-accounts-title" className="text-ui-lg">音源账号</h2>
</div>
<ul className="admin-list">
{sources.map(source => <SourceAccount key={source.name} source={source} />)}
<section className="admin-section" aria-labelledby="admin-sources-title">
<SectionHead id="admin-sources-title" title="音源" description="扫码登录各平台的账号,用来搜索和播放它们的歌曲。" />
<div className="admin-cards">
{(Object.keys(sources) as SourceId[]).map(id => <SourceCard key={id} source={sources[id]} credential={credentials[id]} />)}
<YouTubeCookies />
</ul>
</div>
</section>
)
}
@@ -0,0 +1,77 @@
import type { QrLoginTarget } from './qr-login-dialog'
import type { CredentialState } from './source-account-view'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { adminStore } from './admin-store'
export interface Source {
name: string
credential: () => Promise<Exclude<CredentialState, Error | undefined>>
targets: QrLoginTarget[]
}
export type SourceId = 'netease' | 'qq' | 'bilibili'
export const sources: Record<SourceId, Source> = {
netease: {
name: '网易云音乐',
credential: () => api.adminGetNeteaseCredential(),
targets: [
{ label: '网易云音乐扫码', start: () => api.adminStartNeteaseLogin(), check: key => api.adminCheckNeteaseLogin({ key }) },
],
},
qq: {
name: 'QQ 音乐',
credential: () => api.adminGetQqCredential(),
targets: [
{ label: 'QQ 扫码', start: () => api.adminStartQqLogin({ type: 'qq' }), check: key => api.adminCheckQqLogin({ type: 'qq', key }) },
{ label: '微信扫码', start: () => api.adminStartQqLogin({ type: 'wx' }), check: key => api.adminCheckQqLogin({ type: 'wx', key }) },
],
},
bilibili: {
name: '哔哩哔哩',
credential: () => api.adminGetBilibiliCredential(),
targets: [
{ label: '哔哩哔哩扫码', start: () => api.adminStartBilibiliLogin(), check: key => api.adminCheckBilibiliLogin({ key }) },
],
},
}
export interface Credential {
state: CredentialState
reload: () => void
}
function useCredential(source: Source): Credential {
const [state, setState] = useState<CredentialState>()
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let active = true
adminStore.attempt(source.credential).then((result) => {
if (active)
setState(result)
}, (error: Error) => {
if (active)
setState(error)
})
return () => {
active = false
}
}, [source, attempt])
return {
state,
reload: () => {
setState(undefined)
setAttempt(n => n + 1)
},
}
}
// 分类栏要根据登录状态给“音源”加红点,所以后台页一打开就查询,不等打开这个分类。
export function useSourceCredentials(): Record<SourceId, Credential> {
return {
netease: useCredential(sources.netease),
qq: useCredential(sources.qq),
bilibili: useCredential(sources.bilibili),
}
}
+141 -70
View File
@@ -1,89 +1,160 @@
import type { StoredMedia as Media } from '@/core/realtime'
import { Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
import { bookTitle } from '@/shared/lib/book-title'
import { formatDateTime } from '@/shared/lib/time'
import { ConfirmDialog, IconButton, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
import type { MediaQuery } from './media-store'
import type { MediaSort, StoredMedia } from '@/core/realtime'
import type { SelectOption } from '@/shared/ui'
import { Database, Search, SearchX } from 'lucide-react'
import { useEffect, useEffectEvent, useRef, useState } from 'react'
import { rowAfterRemoval } from '@/shared/lib/removal-focus'
import { useStore } from '@/shared/lib/use-store'
import { ConfirmDialog, EmptyState, notificationStore, Pagination, Select, Skeleton } from '@/shared/ui'
import { fallbackPage, MediaStore } from './media-store'
import { MediaTable } from './media-table'
import { deleteConfirmText, formatSize, removalMessage, searchDraft } from './media-view'
import { rowOrHeading } from './removal-focus'
import { LoadFailed, SectionHead } from './section-head'
function formatSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
const searchDelayMs = 300
// 与接口对 q 的长度限制一致。
const searchMaxLength = 100
const sortOptions: (SelectOption & { value: MediaSort })[] = [
{ value: 'stored', label: '最近存储' },
{ value: 'idle', label: '最久未播放' },
{ value: 'size', label: '占用最大' },
]
// 输入停顿之后才改写地址,输入法组字期间不算停顿:不拿拼音字母去搜索。
function MediaSearch({ q, onSearch }: { q: string, onSearch: (q: string) => void }) {
const [draft, setDraft] = useState(q)
const [seenQ, setSeenQ] = useState(q)
const [composing, setComposing] = useState(false)
const search = useEffectEvent(onSearch)
if (q !== seenQ) {
setSeenQ(q)
setDraft(searchDraft(draft, q))
}
useEffect(() => {
const next = draft.trim()
if (composing || next === q)
return
const timer = setTimeout(search, searchDelayMs, next)
return () => clearTimeout(timer)
}, [draft, q, composing])
return (
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input
type="search"
aria-label="搜索标题"
placeholder="搜索标题"
maxLength={searchMaxLength}
value={draft}
onChange={event => setDraft(event.target.value)}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
/>
</label>
)
}
export function StoredMedia() {
const [media, setMedia] = useState<Media[] | null>(null)
const [failed, setFailed] = useState(false)
const [reload, setReload] = useState(0)
const [deleting, setDeleting] = useState<Media | null>(null)
export function MediaPage({ q, sort, page, onNavigate }: MediaQuery & { onNavigate: (query: MediaQuery) => void }) {
const [store] = useState(() => new MediaStore())
const list = useStore(store, s => s.list)
const loadedAt = useStore(store, s => s.loadedAt)
const loading = useStore(store, s => s.loading)
const error = useStore(store, s => s.error)
const selected = useStore(store, s => s.selected)
const [deleting, setDeleting] = useState<StoredMedia[] | null>(null)
// 删掉的歌之后焦点落到哪一行:null 表示落到标题。
const nextFocusRef = useRef<string | null>(null)
useEffect(() => store.show({ q, sort, page }), [store, q, sort, page])
const fallback = list && !loading && error === null ? fallbackPage(list) : null
const navigate = useEffectEvent(onNavigate)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminListMedia()).then((result) => {
if (!active)
return
if (result)
setMedia(result.items)
else
setFailed(true)
})
return () => {
active = false
}
}, [reload])
const retry = () => {
setFailed(false)
setMedia(null)
setReload(n => n + 1)
if (fallback !== null)
navigate({ q, sort, page: fallback })
}, [fallback, q, sort])
// 页码在表格下面,换页之后回到顶部。用 nearest:start 会把外层不该滚动的页面框架也一起滚上去。
const goToPage = (next: number) => {
onNavigate({ q, sort, page: next })
document.getElementById('admin-media-title')?.scrollIntoView({ block: 'nearest' })
}
const totalSize = media?.reduce((sum, item) => sum + item.size, 0) ?? 0
const chosen = list?.items.filter(item => selected.has(item.key)) ?? []
const content = () => {
if (error !== null)
return <LoadFailed message={`暂时无法获取已存储的媒体。${error}`} onRetry={() => store.retry()} />
if (!list || fallback !== null)
return <Skeleton rows={3} />
if (!list.items.length) {
return q
? (
<EmptyState icon={SearchX} title="没有找到歌曲" action={<button type="button" className="button-secondary" onClick={() => onNavigate({ q: '', sort, page: 1 })}>清除搜索</button>}>
换个标题再找找。
</EmptyState>
)
: <EmptyState icon={Database} title="还没有存储任何歌曲">点过的链接和哔哩哔哩的歌会存在这里。</EmptyState>
}
const first = (list.page - 1) * list.pageSize + 1
return (
<>
<MediaTable
items={list.items}
now={loadedAt}
selected={selected}
busy={loading}
onToggle={(key, shift) => store.toggle(key, shift)}
onToggleAll={() => store.toggleAll()}
onDelete={item => setDeleting([item])}
/>
<Pagination page={page} totalPages={Math.ceil(list.total / list.pageSize)} onPage={goToPage} summary={`第 ${first}–${first + list.items.length - 1} 首`} />
</>
)
}
return (
<section className="admin-section" aria-labelledby="admin-media-title">
<div className="section-heading">
<h2 id="admin-media-title" className="text-ui-lg">已存储的媒体</h2>
{media && <span className="text-ui-sm muted">{media.length ? `${media.length} 首 · ${formatSize(totalSize)}` : '0 首'}</span>}
<SectionHead id="admin-media-title" title="媒体" description="服务器上缓存的音频。删除后,下次有人点这首歌时会重新下载。" />
{chosen.length > 0 && (
<div className="media-selection-anchor">
<div className="media-selection" role="toolbar" aria-label="选中的歌曲">
<span className="media-selection-count">{`已选 ${chosen.length} 首 · ${formatSize(chosen.reduce((sum, item) => sum + item.size, 0))}`}</span>
<button type="button" className="button-destructive" onClick={() => setDeleting(chosen)}>删除</button>
<button type="button" className="button-ghost" onClick={() => store.clearSelection()}>取消</button>
</div>
</div>
)}
<div className="media-toolbar" data-hidden={chosen.length > 0 || undefined}>
<MediaSearch q={q} onSearch={next => onNavigate({ q: next, sort, page: 1 })} />
<Select className="media-sort" ariaLabel="排序" value={sort} options={sortOptions} onChange={next => onNavigate({ q, sort: next as MediaSort, page: 1 })} />
{list && list.total > 0 && <span className="media-total">{`共 ${list.total.toLocaleString('zh-CN')} 首 · 占用 ${formatSize(list.totalBytes)}`}</span>}
</div>
{failed
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取已存储的媒体。</p>
<button type="button" className="button-secondary" onClick={retry}>重试</button>
</div>
)
: !media
? <Skeleton rows={3} />
: !media.length
? <p className="admin-empty text-ui-caption muted">还没有存储任何歌曲。</p>
: (
<ul className="admin-list">
{media.map(item => (
<li className="admin-row" key={item.key}>
<div className="admin-row-copy">
<strong className="text-ui-base truncate">{item.title}</strong>
<a className="admin-link text-ui-sm truncate" href={item.link} target="_blank" rel="noopener noreferrer">{item.link}</a>
<span className="text-ui-xs muted">
{`${item.source === 'bilibili' ? '哔哩哔哩 · ' : ''}${formatSize(item.size)} · 存入于 ${formatDateTime(item.storedAtMs)} · ${item.lastPlayedAtMs === null ? '还没有播放过' : `最近播放于 ${formatDateTime(item.lastPlayedAtMs)}`}`}
</span>
</div>
<IconButton icon={Trash2} label={`删除${bookTitle(item.title)}`} onClick={() => setDeleting(item)} />
</li>
))}
</ul>
)}
{content()}
{deleting && (
<ConfirmDialog
title="删除已存储的媒体"
confirmLabel="删除"
confirmLabel={deleting.length > 1 ? `删除 ${deleting.length} 首` : '删除'}
danger
onConfirm={async () => {
if (await adminStore.call(() => api.adminDeleteMedia(deleting.key)) !== null) {
setDeleting(null)
setFailed(false)
setReload(n => n + 1)
}
const before = list?.items.map(item => item.key) ?? []
const keys = deleting.map(item => item.key)
const result = await store.remove(keys)
if (!result)
return
nextFocusRef.current = rowAfterRemoval(before, store.state.list?.items.map(item => item.key) ?? [], keys)
setDeleting(null)
const message = removalMessage(deleting.length, result.inUse.length)
if (result.inUse.length)
notificationStore.info(message, 5000)
else
notificationStore.success(message)
}}
onClose={() => setDeleting(null)}
returnFocus={() => rowOrHeading('admin-media-title', nextFocusRef.current)}
>
{`删除${bookTitle(deleting.title)}的音频和封面后,再点这首歌会重新下载。`}
{deleteConfirmText(deleting)}
</ConfirmDialog>
)}
</section>
@@ -1,6 +1,7 @@
import type { AdminGetYoutubeCredentialResponse } from '@/core/api'
export type YoutubeCredentialState = AdminGetYoutubeCredentialResponse | null | undefined
// undefined 表示正在查询,Error 表示查询失败,message 是给用户看的原因。
export type YoutubeCredentialState = AdminGetYoutubeCredentialResponse | Error | undefined
export const youtubeCookiesText = {
plaintextWarning: '当前连接不是 HTTPS,填写的 cookies 会以明文在网络上传输,只在可信网络中填写。',
@@ -28,8 +29,8 @@ export function shouldWarnAboutPlaintext(location: Pick<Location, 'protocol' | '
export function youtubeCookiesView(state: YoutubeCredentialState): { status: string, updatedAtMs: number | null } {
if (state === undefined)
return { status: '正在查询…', updatedAtMs: null }
if (state === null)
return { status: '正在查询', updatedAtMs: null }
if (state instanceof Error)
return { status: '查询失败', updatedAtMs: null }
if (!state.set)
return { status: '未配置', updatedAtMs: null }
@@ -62,9 +62,9 @@ describe('youtubeCookiesText', () => {
})
describe('youtubeCookiesView', () => {
const cases: { name: string, state: AdminGetYoutubeCredentialResponse | null | undefined, want: ReturnType<typeof youtubeCookiesView> }[] = [
{ name: '正在查询', state: undefined, want: { status: '正在查询…', updatedAtMs: null } },
{ name: '查询失败', state: null, want: { status: '查询失败', updatedAtMs: null } },
const cases: { name: string, state: AdminGetYoutubeCredentialResponse | Error | undefined, want: ReturnType<typeof youtubeCookiesView> }[] = [
{ name: '正在查询', state: undefined, want: { status: '正在查询', updatedAtMs: null } },
{ name: '查询失败', state: new Error('网络不通'), want: { status: '查询失败', updatedAtMs: null } },
{ name: '未配置', state: { set: false, updatedAtMs: 0 }, want: { status: '未配置', updatedAtMs: null } },
{ name: '已配置并显示更新时间', state: { set: true, updatedAtMs: 1_793_184_000_000 }, want: { status: '已配置', updatedAtMs: 1_793_184_000_000 } },
]
+25 -11
View File
@@ -4,6 +4,7 @@ import { api } from '@/core/api'
import { formatDateTime } from '@/shared/lib/time'
import { ConfirmDialog, Modal, notificationStore } from '@/shared/ui'
import { adminStore } from './admin-store'
import { StatusPill } from './section-head'
import { canSubmitYoutubeCookies, shouldWarnAboutPlaintext, youtubeCookiesText, youtubeCookiesTooLarge, youtubeCookiesView } from './youtube-cookies-view'
function YouTubeCookiesDialog({ onSaved, onClose }: { onSaved: () => void, onClose: () => void }) {
@@ -65,15 +66,19 @@ function YouTubeCookiesDialog({ onSaved, onClose }: { onSaved: () => void, onClo
}
export function YouTubeCookies() {
const titleId = useId()
const [credential, setCredential] = useState<YoutubeCredentialState>()
const [attempt, setAttempt] = useState(0)
const [editing, setEditing] = useState(false)
const [clearing, setClearing] = useState(false)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminGetYoutubeCredential()).then((result) => {
adminStore.attempt(() => api.adminGetYoutubeCredential()).then((result) => {
if (active)
setCredential(result)
}, (error: Error) => {
if (active)
setCredential(error)
})
return () => {
active = false
@@ -85,16 +90,23 @@ export function YouTubeCookies() {
setAttempt(n => n + 1)
}
return (
<li className="admin-row">
<div className="admin-row-copy">
<strong className="text-ui-base">YouTube</strong>
<span className="text-ui-sm muted" role="status">{view.status}</span>
{view.updatedAtMs !== null && <span className="text-ui-sm muted">{`最近填写于 ${formatDateTime(view.updatedAtMs)}`}</span>}
{shouldWarnAboutPlaintext(window.location) && <span className="error-text text-ui-sm" role="alert">{youtubeCookiesText.plaintextWarning}</span>}
<article className="admin-card" aria-labelledby={titleId}>
<div className="admin-card-top">
<h3 id={titleId}>YouTube</h3>
<StatusPill tone="neutral">{view.status}</StatusPill>
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" disabled={credential === undefined} onClick={() => setEditing(true)}>{credential?.set ? '覆盖 cookies' : '填写 cookies'}</button>
{credential?.set && <button type="button" className="button-secondary" onClick={() => setClearing(true)}>清除</button>}
{credential instanceof Error && <p className="admin-card-detail">{credential.message}</p>}
{view.updatedAtMs !== null && <p className="admin-card-detail">{`${formatDateTime(view.updatedAtMs)} 更新`}</p>}
{shouldWarnAboutPlaintext(window.location) && <p className="admin-card-detail error-text" role="alert">{youtubeCookiesText.plaintextWarning}</p>}
<div className="admin-card-actions">
{credential instanceof Error
? <button type="button" className="button-secondary" onClick={refetch}>重试</button>
: (
<>
<button type="button" className="button-secondary" disabled={credential === undefined} onClick={() => setEditing(true)}>{credential?.set ? '更新' : '填写 cookies'}</button>
{credential?.set && <button type="button" className="button-secondary" onClick={() => setClearing(true)}>清除</button>}
</>
)}
</div>
{editing && (
<YouTubeCookiesDialog
@@ -106,6 +118,7 @@ export function YouTubeCookies() {
onClose={() => setEditing(false)}
/>
)}
{/* 清除之后“清除”按钮不再显示,重新读取期间“填写 cookies”也不能点:焦点落到分类标题。 */}
{clearing && (
<ConfirmDialog
title="清除 YouTube cookies"
@@ -119,10 +132,11 @@ export function YouTubeCookies() {
notificationStore.success('已清除 YouTube cookies')
}}
onClose={() => setClearing(false)}
returnFocus={() => document.getElementById('admin-sources-title')}
>
清除后,下载 YouTube 链接时不再携带 cookies。
</ConfirmDialog>
)}
</li>
</article>
)
}
+4 -2
View File
@@ -1,6 +1,6 @@
import type { MyListen } from '@/core/realtime'
import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { roomStore, sameTrack } from '@/core/room'
import { SaveToPlaylistButton } from '@/features/playlists'
import { enqueue, sourceName } from '@/features/queue'
@@ -68,6 +68,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
const [limit, setLimit] = useState(batchSize)
const [confirming, setConfirming] = useState(false)
const titleRef = useRef<HTMLHeadingElement>(null)
useEffect(() => historyStore.open(), [])
const all = listens ?? []
const visible = all.filter(listen => matches(listen, filter))
@@ -109,7 +110,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac
{!compact && (
<>
<div className="page-title">
<h1 className="text-ui-xl">播放历史</h1>
<h1 ref={titleRef} className="text-ui-xl" tabIndex={-1}>播放历史</h1>
<p className="text-ui-base muted">重温那些一起听过的旋律。</p>
</div>
<div className="history-toolbar">
@@ -149,6 +150,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac
setConfirming(false)
}}
onClose={() => setConfirming(false)}
returnFocus={() => titleRef.current}
>
将清空你的全部播放记录,登录同一个账号的其他设备上也会一起清空。
</ConfirmDialog>
+1 -1
View File
@@ -2,5 +2,5 @@ export { AudioEngine } from './audio-engine'
export { ManualStartPrompt } from './manual-start-prompt'
export { PlayerBar } from './player-bar'
export { playerStore } from './player-store'
export { reminderStore, requestNotificationPermission, systemNotificationsSupported } from './reminder-store'
export { reminderStore, systemNotificationsSupported } from './reminder-store'
export { volumeStore } from './volume-store'
@@ -19,11 +19,6 @@ export function systemNotificationsSupported(): boolean {
return 'Notification' in window && 'serviceWorker' in navigator
}
// 只能在用户操作的事件里调用:Safari 和 Firefox 不接受其他时候发起的权限申请。
export async function requestNotificationPermission(): Promise<boolean> {
return await Notification.requestPermission() === 'granted'
}
class ReminderStore extends Store<ReminderSettings> {
#room: RoomState | null = null
#firstSeen = new Map<string, number>()
@@ -1,5 +1,6 @@
import type { MyPlaylist } from '@/core/realtime'
import { useState } from 'react'
import { isImeKey } from '@/shared/lib/ime'
import { playlistsStore } from './playlists-store'
export function CreatePlaylistForm({ onCreated, onCancel }: { onCreated: (playlist: MyPlaylist) => void, onCancel: () => void }) {
@@ -9,7 +10,7 @@ export function CreatePlaylistForm({ onCreated, onCancel }: { onCreated: (playli
<form
className="form-stack"
onKeyDown={(event) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing)
if (event.key !== 'Escape' || isImeKey(event.nativeEvent))
return
// 拦下对话框的 Esc:在“保存到歌单”里,Esc 只回到列表。
event.preventDefault()
+1 -2
View File
@@ -1,8 +1,7 @@
import type { MyPlaylist } from '@/core/realtime'
import { FileUp, Import, Link } from 'lucide-react'
import { useRef, useState } from 'react'
import { Modal, notificationStore } from '@/shared/ui'
import { ActionMenu } from './action-menu'
import { ActionMenu, Modal, notificationStore } from '@/shared/ui'
import { importLimit, parsePlaylistFile } from './playlist-file'
import { playlistsStore } from './playlists-store'
import { defaultImportName } from './save-platform-playlist'
@@ -2,12 +2,13 @@ import type { MovePlan } from './track-order'
import type { MyPlaylist, MyPlaylistItem, Track } from '@/core/realtime'
import type { SongAction } from '@/shared/ui'
import { ArrowLeft, CirclePlus, ListMusic, ListPlus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { api } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { roomStore } from '@/core/room'
import { enqueue } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { rowAfterRemoval } from '@/shared/lib/removal-focus'
import { useStore } from '@/shared/lib/use-store'
import { ConfirmDialog, EmptyState, notificationStore, Skeleton } from '@/shared/ui'
import { FavoritesMark, PlaylistCover } from './playlist-cover'
@@ -60,6 +61,9 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
const [savingSelected, setSavingSelected] = useState(false)
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
// 移出之后焦点要去的那一首(null 表示歌单名):移出的行、随选择清空而消失的选择栏和确认框都带走了焦点。每次换一个新对象,同一个目标也能再次触发。
const [refocus, setRefocus] = useState<{ key: string | null } | null>(null)
const titleRef = useRef<HTMLHeadingElement>(null)
const [edits] = useState(() => new TrackEdits(() => setAttempt(n => n + 1)))
// 歌单的更新时间变了就重新读取:加入、移出、移动和复制都会推进它,歌曲数不变时顺序也可能变了。
useEffect(() => {
@@ -85,6 +89,14 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
return () => window.removeEventListener('focus', refresh)
}, [])
const selecting = picking || selection.keys.size > 0
// 多选时落到勾选框上,平时落到行上,和列表里的方向键一样。
useEffect(() => {
if (!refocus)
return
const row = refocus.key === null ? null : document.querySelector<HTMLElement>(`.my-playlist-page [data-key="${CSS.escape(refocus.key)}"]`)
const target = row?.querySelector<HTMLElement>('.playlist-track-check') ?? row ?? titleRef.current
target?.focus()
}, [refocus])
// 对话框和菜单自己处理 Esc。
useEffect(() => {
if (!selecting)
@@ -124,6 +136,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
const previousSelection = selection
setItems(rest)
setSelection(retain(selection, keysOf(rest)))
setRefocus({ key: rowAfterRemoval(keysOf(current), keysOf(rest), [...gone]) })
await edits.run(() => playlistsStore.removeItems(target, tracks), () => {
setItems(current)
setSelection(previousSelection)
@@ -227,7 +240,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
<header className="my-playlist-header">
<PlaylistCover cover={playlist?.coverUrl ?? null} favorites={playlist?.favorites ?? false} size="lg" />
<div className="my-playlist-info">
<h1 id="my-playlist-title" className="my-playlist-name">
<h1 ref={titleRef} id="my-playlist-title" className="my-playlist-name" tabIndex={-1}>
{playlist || listed ? <span>{playlist?.name ?? '歌单'}</span> : <span className="my-playlist-name-pending" role="img" aria-label="正在加载" />}
{playlist?.favorites && <FavoritesMark size={22} />}
</h1>
+2 -3
View File
@@ -1,9 +1,8 @@
import type { MenuAction } from './action-menu'
import type { MyPlaylist } from '@/core/realtime'
import type { MenuAction } from '@/shared/ui'
import { Copy, Ellipsis, FileDown, Link2Off, Pencil, RefreshCw, Share2, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { ConfirmDialog, Modal, notificationStore } from '@/shared/ui'
import { ActionMenu } from './action-menu'
import { ActionMenu, ConfirmDialog, Modal, notificationStore } from '@/shared/ui'
import { playlistsStore } from './playlists-store'
async function copyShareLink(shareId: string, copied: string): Promise<void> {
-7
View File
@@ -31,15 +31,8 @@
.playlist-cover-lg > .playlist-cover-badge > svg { width: 17px; height: 17px; }
.playlist-favorites-mark { flex-shrink: 0; color: var(--color-brand); }
.playlist-menu { inset: auto; margin: 0; min-width: 148px; padding: 6px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 12px 32px var(--color-shadow); transform-origin: top center; }
.playlist-menu:popover-open { display: flex; flex-direction: column; gap: 2px; animation: popover-in 160ms cubic-bezier(.2, .8, .2, 1) both; }
.playlist-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); text-align: left; white-space: nowrap; }
.playlist-menu-item:hover, .playlist-menu-item:focus-visible { outline: none; background: var(--color-hover); }
.playlist-menu-item > svg { color: var(--color-foreground-subtle); }
.playlist-menu-danger, .playlist-menu-danger > svg { color: var(--color-destructive); }
.playlist-menu-button { color: var(--color-foreground-subtle); }
.playlist-menu-button:hover:not(:disabled), .playlist-menu-button[aria-expanded='true'] { color: var(--color-foreground); }
:root[data-performance='low'] .playlist-menu:popover-open { animation: fade-in 160ms ease both; }
.playlists-page { container: playlists / inline-size; }
.playlists-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: 'heading controls create'; align-items: end; gap: 12px; margin-bottom: 28px; }
@@ -1,156 +1,21 @@
import type { FormEvent } from 'react'
import type { Account, GitHubIntent } from '@/core/realtime'
import { useEffect, useId, useState } from 'react'
import { useId, useState } from 'react'
import { api, ApiError, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { useStore } from '@/shared/lib/use-store'
import { Modal, notificationStore } from '@/shared/ui'
import { startGithubLogin, takeCredentialsPrompt } from './github'
import { PairingCodeForm, PairingDialog } from './pairing-dialog'
import { loginWithPassword } from './password-login'
import { profileStore, switchIdentity } from './profile-store'
// credentials:只绑定了 GitHub 的账号补设用户名和密码,用的是注册接口。
type Dialog = 'register' | 'login' | 'credentials' | 'password' | 'pairing' | 'redeem' | null
const credentialTexts = { INVALID_ARGUMENT: '用户名要 3–32 位小写字母、数字、_ 或 -,密码要 8–128 位。' }
// 对话框渲染在 portal 中,React 的事件仍会冒泡到设置页外层的表单,提交时要拦住,否则会顺带保存整页设置。
function submitted(event: FormEvent, run: () => Promise<void>): void {
event.preventDefault()
event.stopPropagation()
void run()
}
// 返回 false 时,失败原因已经通过通知显示;成功时页面随即跳到 GitHub。
async function goToGitHub(intent: GitHubIntent, currentPassword?: string): Promise<boolean> {
try {
window.location.assign(await startGithubLogin(intent, currentPassword))
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '没有跳转到 GitHub,再试一次。' : errorMessage(error))
return false
}
}
export function AccountRow() {
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const failed = useStore(profileStore, s => s.failed)
const [site, setSite] = useState({ registrationOpen: false, githubLogin: false })
const [dialog, setDialog] = useState<Dialog>(() => takeCredentialsPrompt() ? 'credentials' : null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
api.getSite().then(({ registrationOpen, githubLogin }) => {
if (active)
setSite({ registrationOpen, githubLogin })
}, () => {})
return () => {
active = false
}
}, [])
const logout = async () => {
setBusy(true)
if (!await profileStore.logout())
setBusy(false)
}
const github = async (intent: GitHubIntent) => {
setBusy(true)
if (!await goToGitHub(intent))
setBusy(false)
}
const description = member ? null : failed ? '暂时无法获取你的资料。' : '正在获取你的资料…'
return (
<>
<div className="settings-row">
<div className="settings-row-copy">
<p className="settings-row-title text-ui-base">{account ? account.username ?? '用 GitHub 登录的账号' : '游客'}</p>
{description && <p className="text-ui-caption muted">{description}</p>}
</div>
{member && (
<div className="settings-row-control settings-account-actions">
{account
? (
<>
<button type="button" className="button-secondary" onClick={() => setDialog('pairing')}>在新设备上登录</button>
{account.username
? <button type="button" className="button-secondary" onClick={() => setDialog('password')}>修改密码</button>
: <button type="button" className="button-secondary" disabled={busy} onClick={() => void github('verify')}>设置用户名和密码</button>}
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logout()}>退出登录</button>
</>
)
: (
<>
{site.registrationOpen && <button type="button" className="button-secondary" onClick={() => setDialog('register')}>注册</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('login')}>登录</button>
{site.githubLogin && <button type="button" className="button-secondary" disabled={busy} onClick={() => void github('login')}>用 GitHub 登录</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('redeem')}>输入配对码</button>
</>
)}
</div>
)}
{(dialog === 'register' || dialog === 'login' || dialog === 'credentials') && <CredentialsDialog mode={dialog} onClose={() => setDialog(null)} />}
{dialog === 'password' && <PasswordDialog onClose={() => setDialog(null)} />}
{dialog === 'pairing' && <PairingDialog onClose={() => setDialog(null)} />}
{dialog === 'redeem' && (
<Modal title="输入配对码" onClose={() => setDialog(null)}>
<PairingCodeForm onSignedIn={switchIdentity} />
</Modal>
)}
</div>
{account && site.githubLogin && <GitHubRow account={account} />}
</>
)
}
function GitHubRow({ account }: { account: Account }) {
const [dialog, setDialog] = useState<'bind' | 'unbind' | null>(null)
const unbind = async (currentPassword: string): Promise<boolean> => {
try {
await api.unbindGithub({ currentPassword })
profileStore.updateAccount({ ...account, github: false })
notificationStore.success('已解绑 GitHub')
setDialog(null)
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '没有解绑,再试一次。' : errorMessage(error))
return false
}
}
const description = !account.github
? '绑定之后,可以用 GitHub 登录这个账号。'
: account.username
? '已绑定,可以用 GitHub 登录这个账号。'
: '已绑定。先设置用户名和密码,才能解绑。'
return (
<div className="settings-row">
<div className="settings-row-copy">
<p className="settings-row-title text-ui-base">GitHub</p>
<p className="text-ui-caption muted">{description}</p>
</div>
<div className="settings-row-control settings-account-actions">
{account.github
? <button type="button" className="button-secondary" disabled={!account.username} onClick={() => setDialog('unbind')}>解绑</button>
: <button type="button" className="button-secondary" onClick={() => setDialog('bind')}>绑定 GitHub</button>}
</div>
{dialog && (
<CurrentPasswordDialog
title={dialog === 'bind' ? '绑定 GitHub' : '解绑 GitHub'}
action={dialog === 'bind' ? '去 GitHub 授权' : '解绑'}
onConfirm={dialog === 'bind' ? currentPassword => goToGitHub('bind', currentPassword) : unbind}
onClose={() => setDialog(null)}
/>
)}
</div>
)
}
// 绑定和解绑 GitHub 都会更换登录方式,先输入当前密码:只凭这台设备上的登录不能更换。onConfirm 返回 false 时,失败原因已经通过通知显示;
// 绑定成功时页面随即跳到 GitHub,对话框保持忙碌。
function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: string, action: string, onConfirm: (currentPassword: string) => Promise<boolean>, onClose: () => void }) {
export function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: string, action: string, onConfirm: (currentPassword: string) => Promise<boolean>, onClose: () => void }) {
const [currentPassword, setCurrentPassword] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
@@ -171,7 +36,8 @@ function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: s
)
}
function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login' | 'credentials', onClose: () => void }) {
// credentials:只绑定了 GitHub 的账号补设用户名和密码,用的是注册接口。
export function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login' | 'credentials', onClose: () => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [repeated, setRepeated] = useState('')
@@ -193,7 +59,7 @@ function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login' | 'cr
catch (error) {
// 用 GitHub 确认身份已经过期:在对话框里重试也没用,关掉它,从按钮重新确认。
if (mode === 'credentials' && error instanceof ApiError && error.code === 'FORBIDDEN') {
notificationStore.error('用 GitHub 确认身份已经超过 10 分钟,请再点一次“设置用户名和密码”。')
notificationStore.error('用 GitHub 确认身份已经超过 10 分钟,请在“用户名和密码”一行再点一次“设置”。')
onClose()
return
}
@@ -227,7 +93,7 @@ function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login' | 'cr
)
}
function PasswordDialog({ onClose }: { onClose: () => void }) {
export function PasswordDialog({ onClose }: { onClose: () => void }) {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [repeated, setRepeated] = useState('')
+15 -1
View File
@@ -1,6 +1,8 @@
import type { FinishGithubLoginResponse } from '@/core/api'
import type { GitHubIntent } from '@/core/realtime'
import { api } from '@/core/api'
import { api, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { notificationStore } from '@/shared/ui'
// GitHub 跳回来时仍在发起授权的这个标签页,所以 nonce 存在 sessionStorage:别的标签页、别的浏览器拿到兑换码也没有它。
// 意图也存在这里,回来之后据此决定接下来做什么。
@@ -26,6 +28,18 @@ export async function startGithubLogin(intent: GitHubIntent, currentPassword?: s
return url
}
// 返回 false 时,失败原因已经通过通知显示;成功时页面随即跳到 GitHub。
export async function goToGitHub(intent: GitHubIntent, currentPassword?: string): Promise<boolean> {
try {
window.location.assign(await startGithubLogin(intent, currentPassword))
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '没有跳转到 GitHub,再试一次。' : errorMessage(error))
return false
}
}
export type GithubLanding
= | { kind: 'exchange', code: string, nonce: string, intent: GitHubIntent }
| { kind: 'failed', message: string }
+1
View File
@@ -3,6 +3,7 @@ import { lazyPage } from '@/shared/ui'
export { appearanceStore } from './appearance-store'
export { performanceStore } from './performance-store'
export { profileStore } from './profile-store'
export type { SettingsSection } from './settings-page'
export const SettingsPage = lazyPage(() => import('./settings-page').then(module => module.SettingsPage))
export const PairPage = lazyPage(() => import('./pair-page').then(module => module.PairPage))
export const GitHubLoginPage = lazyPage(() => import('./github-login-page').then(module => module.GitHubLoginPage))
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NotificationPermissionRequest } from './notification-permission'
// 浏览器的 Notification:每次申请都弹出一个提示,由测试决定用户怎么回应、什么时候回应。
class FakeNotification {
static permission: NotificationPermission = 'default'
static prompts: ((permission: NotificationPermission) => void)[] = []
static requestPermission(): Promise<NotificationPermission> {
return new Promise((resolve) => {
FakeNotification.prompts.push((permission) => {
FakeNotification.permission = permission
resolve(permission)
})
})
}
}
function answer(index: number, permission: NotificationPermission): void {
FakeNotification.prompts[index]!(permission)
}
beforeEach(() => {
FakeNotification.permission = 'default'
FakeNotification.prompts = []
vi.stubGlobal('Notification', FakeNotification)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('申请系统通知的权限', () => {
it('从浏览器读到当前的权限', () => {
FakeNotification.permission = 'denied'
expect(new NotificationPermissionRequest().state).toEqual({ permission: 'denied', waiting: false, refused: false })
})
it('申请之后等待浏览器回应,允许之后拿到授权', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
expect(request.state).toEqual({ permission: 'default', waiting: true, refused: false })
answer(0, 'granted')
expect(await result).toBe(true)
expect(request.state).toEqual({ permission: 'granted', waiting: false, refused: false })
})
it('浏览器拒绝时没有拿到授权', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
answer(0, 'denied')
expect(await result).toBe(false)
expect(request.state).toEqual({ permission: 'denied', waiting: false, refused: true })
})
it('用户关掉提示、权限仍是 default 时,也算没有拿到授权', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
answer(0, 'default')
expect(await result).toBe(false)
expect(request.state).toEqual({ permission: 'default', waiting: false, refused: true })
})
it('提示一直不返回时可以放弃,放弃之后提示才返回也不算这次申请的结果,只更新权限', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
request.abandon()
expect(request.state).toEqual({ permission: 'default', waiting: false, refused: false })
answer(0, 'granted')
expect(await result).toBe(false)
expect(request.state).toEqual({ permission: 'granted', waiting: false, refused: false })
})
it('放弃后被拒绝的提示晚回来,不显示没有拿到授权', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
request.abandon()
answer(0, 'denied')
expect(await result).toBe(false)
expect(request.state).toEqual({ permission: 'denied', waiting: false, refused: false })
})
it('放弃之后重新申请,之前的提示晚回来不结束新的等待', async () => {
const request = new NotificationPermissionRequest()
const first = request.request()
request.abandon()
const second = request.request()
answer(0, 'default')
expect(await first).toBe(false)
expect(request.state).toEqual({ permission: 'default', waiting: true, refused: false })
answer(1, 'granted')
expect(await second).toBe(true)
expect(request.state).toEqual({ permission: 'granted', waiting: false, refused: false })
})
it('再次申请时清掉上次没有拿到授权的说明', async () => {
const request = new NotificationPermissionRequest()
const first = request.request()
answer(0, 'default')
await first
void request.request()
expect(request.state).toEqual({ permission: 'default', waiting: true, refused: false })
})
})
@@ -0,0 +1,38 @@
import { Store } from '@/shared/lib/store'
interface NotificationPermissionState {
// 浏览器不通知权限的变化,构造时和每次申请之后各读一次。
permission: NotificationPermission
waiting: boolean
// 最近一次申请没有拿到授权。
refused: boolean
}
// 浏览器的授权提示可能一直不返回:用户没理会,或者浏览器把提示收进了地址栏。所以等待期间可以放弃;
// 放弃之后提示才返回时只更新权限,不算这次申请的结果。
export class NotificationPermissionRequest extends Store<NotificationPermissionState> {
#latest = 0
constructor() {
super({ permission: Notification.permission, waiting: false, refused: false })
}
// 只能在用户操作的事件里调用:Safari 和 Firefox 不接受其他时候发起的权限申请。
// 返回这次申请是否拿到了授权;等待期间被放弃,或者又发起了新的申请时为 false。
async request(): Promise<boolean> {
const id = ++this.#latest
this.setState({ waiting: true, refused: false })
const permission = await Notification.requestPermission()
if (id !== this.#latest) {
this.setState({ permission })
return false
}
this.setState({ permission, waiting: false, refused: permission !== 'granted' })
return permission === 'granted'
}
abandon(): void {
this.#latest++
this.setState({ waiting: false, refused: false })
}
}
+28 -11
View File
@@ -39,8 +39,8 @@ let patchError: { status: number, code: string } | null
let extraRoutes: Map<string, () => Response>
async function load() {
const [{ profileStore, switchIdentity }, { identityEvents }] = await Promise.all([import('./profile-store'), import('@/core/api')])
return { profileStore, switchIdentity, identityEvents }
const [{ profileStore, switchIdentity }, { identityEvents }, { notificationStore }] = await Promise.all([import('./profile-store'), import('@/core/api'), import('@/shared/ui')])
return { profileStore, switchIdentity, identityEvents, notificationStore }
}
beforeEach(() => {
@@ -232,12 +232,12 @@ describe('邮箱', () => {
const { profileStore } = await load()
await profileStore.load()
expect(await profileStore.save({ email: ' Alice@Example.COM ' })).toBe(true)
await profileStore.save({ email: ' Alice@Example.COM ' })
expect(updates).toEqual([{ email: ' Alice@Example.COM ' }])
expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM', github: false } })
expect(local.getItem(emailKey)).toBeNull()
expect(await profileStore.save({ email: '' })).toBe(true)
await profileStore.save({ email: '' })
expect(profileStore.state.email).toBe('')
expect(profileStore.state.account).toEqual({ username: 'alice', github: false })
})
@@ -245,7 +245,7 @@ describe('邮箱', () => {
it('游客保存到本机', async () => {
const { profileStore } = await load()
await profileStore.load()
expect(await profileStore.save({ email: 'guest@example.com' })).toBe(true)
await profileStore.save({ email: 'guest@example.com' })
expect(local.getItem(emailKey)).toBe('guest@example.com')
expect(profileStore.state.email).toBe('guest@example.com')
})
@@ -323,12 +323,28 @@ describe('邮箱', () => {
expect(updates).toEqual([])
})
it('保存失败时抛出按字段说明的原因,不弹通知,资料不变', async () => {
me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } }
const { profileStore, notificationStore } = await load()
await profileStore.load()
patchError = { status: 400, code: 'INVALID_ARGUMENT' }
await expect(profileStore.save({ email: 'alice' })).rejects.toThrow('邮箱要写成 name@example.com 这样的格式。')
await expect(profileStore.save({ name: '' })).rejects.toThrow('昵称不能为空。')
patchError = null
patchFails = true
await expect(profileStore.save({ name: 'bob' })).rejects.toThrow('无法连接服务器,请检查网络后再试。')
expect(profileStore.state).toMatchObject({ member, email: 'alice@example.com' })
expect(notificationStore.state.notifications).toEqual([])
})
it('用户清空账号的邮箱时一并删除本机残留的副本,之后加载不再转存', async () => {
local.setItem(emailKey, 'guest@example.com')
me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } }
const { profileStore } = await load()
await profileStore.load()
expect(await profileStore.save({ email: '' })).toBe(true)
await profileStore.save({ email: '' })
expect(local.getItem(emailKey)).toBeNull()
updates.length = 0
@@ -358,20 +374,21 @@ describe('记录播放历史', () => {
expect(profileStore.state.recordListens).toBeNull()
await profileStore.load()
expect(profileStore.state.recordListens).toBe(false)
expect(await profileStore.setRecordListens(true)).toBe(true)
await profileStore.setRecordListens(true)
expect(profileStore.state.recordListens).toBe(true)
expect(await profileStore.setRecordListens(false)).toBe(true)
await profileStore.setRecordListens(false)
expect(profileStore.state.recordListens).toBe(false)
expect(switched).toEqual(['on', 'off'])
})
it('修改失败时开关不变', async () => {
it('修改失败时抛出原因,不弹通知,开关不变', async () => {
me = { member, recordListens: true }
extraRoutes.set('DELETE /api/me/listen-recording', () => new Response(JSON.stringify({ error: { code: 'UNAVAILABLE', message: '暂时不可用' } }), { status: 503 }))
const { profileStore } = await load()
const { profileStore, notificationStore } = await load()
await profileStore.load()
expect(await profileStore.setRecordListens(false)).toBe(false)
await expect(profileStore.setRecordListens(false)).rejects.toThrow('暂时不可用')
expect(profileStore.state.recordListens).toBe(true)
expect(notificationStore.state.notifications).toEqual([])
})
it('身份被拒绝时开关未知,换成新的游客之后是默认的开启', async () => {
+15 -12
View File
@@ -67,25 +67,27 @@ class ProfileStore extends Store<ProfileState> {
return true
}
// 返回 false 时,失败原因已经通过通知显示。
async save(changes: { name?: string, email?: string }): Promise<boolean> {
return await this.#update(changes) === null
// 一次只改一项,失败时才能说清是哪一项不合格:客户端不能按服务端的 message 区分字段。失败时抛出 Error,message 是给用户看的原因。
async save(changes: { name: string } | { email: string }): Promise<void> {
const error = await this.#update(changes)
if (error === null)
return
const invalid = 'name' in changes ? '昵称不能为空。' : '邮箱要写成 name@example.com 这样的格式。'
throw new Error(error instanceof IdentityRenewedError ? '没有保存,再试一次。' : errorMessage(error, { INVALID_ARGUMENT: invalid }))
}
// 返回 false 时,失败原因已经通过通知显示。
async setRecordListens(on: boolean): Promise<boolean> {
// 失败时抛出 Error,message 是给用户看的原因。
async setRecordListens(on: boolean): Promise<void> {
try {
await (on ? api.enableListenRecording() : api.disableListenRecording())
this.setState({ recordListens: on })
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '设置没有保存,再保存一次。' : errorMessage(error))
return false
throw new Error(error instanceof IdentityRenewedError ? '没有保存,再试一次。' : errorMessage(error))
}
this.setState({ recordListens: on })
}
// 失败时返回错误,失败原因已经通过通知显示。
// 失败时返回错误。
async #update(changes: { name?: string, email?: string }): Promise<unknown> {
try {
const { member } = await api.updateMe(changes)
@@ -109,8 +111,6 @@ class ProfileStore extends Store<ProfileState> {
return null
}
catch (error) {
// 客户端不能按服务端的 message 区分是哪个字段不合格。
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
return error
}
}
@@ -137,6 +137,9 @@ class ProfileStore extends Store<ProfileState> {
if (!email)
return
const error = await this.#update({ email })
if (error === null)
return
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
// 服务端不接受的副本重试也不会成功,删掉免得每次加载都报错;其他错误(网络、服务端故障)留到下次再试。
if (error instanceof ApiError && error.code === 'INVALID_ARGUMENT')
localStorage.removeItem(emailKey)
+53
View File
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { SaveStatus, Switch } from '@/shared/ui'
// id 是行内控件的 id:标题用 label 指向它,说明和错误的 id 由它派生。
export function SettingRow({ id, title, description, error, status, labelled = true, children }: {
id: string
title: ReactNode
description?: ReactNode
error?: string | null
status?: ReactNode
// 控件不能被 label 指向时(比如分段控件自带 aria-label)为 false。
labelled?: boolean
children: ReactNode
}) {
return (
<div className="section-row">
<div className="section-row-copy">
{labelled ? <label htmlFor={id} className="section-row-title">{title}</label> : <span className="section-row-title">{title}</span>}
{description && <p id={`${id}-description`} className="section-row-description">{description}</p>}
{error && <p id={`${id}-error`} className="settings-row-error text-ui-caption" role="alert">{error}</p>}
</div>
<div className="settings-row-control">
{status}
{children}
</div>
</div>
)
}
function describedBy(id: string, error: string | null): string {
return error ? `${id}-description ${id}-error` : `${id}-description`
}
// 放在 SettingRow 里的开关,说明和错误由同一个 id 派生。
export function SettingSwitch({ id, checked, error, disabled, onChange }: { id: string, checked: boolean, error: string | null, disabled?: boolean, onChange: (checked: boolean) => void }) {
return <Switch id={id} checked={checked} onChange={onChange} disabled={disabled} aria-describedby={describedBy(id, error)} />
}
export function SwitchRow({ id, title, description, checked, onSave }: {
id: string
title: string
description: ReactNode
checked: boolean
onSave: (checked: boolean) => void | Promise<void>
}) {
const setting = useSettingSave(checked, onSave)
return (
<SettingRow id={id} title={title} description={description} error={setting.state.error} status={<SaveStatus status={setting.state.status} version={setting.savedVersion} />}>
<SettingSwitch id={id} checked={setting.value} error={setting.state.error} onChange={setting.change} />
</SettingRow>
)
}
@@ -0,0 +1,345 @@
import type { Site } from './use-site'
import type { Account, GitHubIntent, Member } from '@/core/realtime'
import { ChevronRight, CircleAlert, Pencil } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { api, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { isImeKey } from '@/shared/lib/ime'
import { useInlineSave } from '@/shared/lib/use-inline-save'
import { savedNoticeMs } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { Avatar, ConfirmDialog, EmptyState, InlineTextField, Modal, notificationStore, SaveStatus } from '@/shared/ui'
import { CredentialsDialog, CurrentPasswordDialog, PasswordDialog } from './account-dialogs'
import { goToGitHub, takeCredentialsPrompt } from './github'
import { PairingCodeForm, PairingDialog } from './pairing-dialog'
import { profileStore, switchIdentity } from './profile-store'
type Dialog = 'register' | 'login' | 'credentials' | 'password' | 'pairing' | 'redeem' | 'bind' | 'unbind' | 'logout' | null
function identityMeta(account: Account | null): string {
if (!account)
return '游客'
return account.username ? `@${account.username}` : '用 GitHub 登录的账号'
}
// 手机端分类列表顶部的身份卡,是账号分类的入口。
export function IdentityEntry({ onOpen }: { onOpen: () => void }) {
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const failed = useStore(profileStore, s => s.failed)
return (
<button type="button" className="settings-identity-entry" data-section="account" onClick={onOpen}>
{member
? <Avatar hash={member.avatarHash} name={member.name} className="settings-identity-entry-avatar" />
: <span className="settings-identity-entry-avatar settings-skeleton" />}
<span className="settings-identity-entry-copy">
<span className="sr-only">账号:</span>
{member || failed
? <span className="settings-identity-entry-name">{member?.name ?? '账号'}</span>
: <span className="settings-skeleton settings-skeleton-line" />}
<span className="settings-identity-entry-meta">{member ? identityMeta(account) : failed ? '资料没有加载出来' : ''}</span>
</span>
<ChevronRight className="settings-chevron" size={18} aria-hidden="true" />
</button>
)
}
// 编辑时才挂载:每次开始编辑都从当前的昵称开始,保存中和失败的状态随编辑结束一起丢掉。
function NameInput({ name, onDone }: { name: string, onDone: (saved: boolean, refocus: boolean) => void }) {
const inputRef = useRef<HTMLInputElement>(null)
// 结束编辑之后输入框随即卸载,有的浏览器会在卸载时触发失焦,不能再提交一次。
const doneRef = useRef(false)
const errorId = useId()
const { state, edit, submit } = useInlineSave(name, value => profileStore.save({ name: value.trim() }))
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const done = (saved: boolean, refocus: boolean) => {
doneRef.current = true
onDone(saved, refocus)
}
const finish = async (fromKeyboard: boolean) => {
const changed = state.draft !== state.saved
if (await submit() && !doneRef.current)
done(changed, fromKeyboard && document.activeElement === inputRef.current)
}
return (
<>
<div className="settings-name">
<input
ref={inputRef}
className="settings-name-input"
value={state.draft}
aria-label="昵称"
maxLength={80}
autoComplete="nickname"
aria-invalid={state.status === 'error'}
aria-describedby={state.error ? errorId : undefined}
aria-busy={state.status === 'saving'}
onChange={event => edit(event.target.value)}
onBlur={() => {
if (!doneRef.current)
void finish(false)
}}
onKeyDown={(event) => {
if (isImeKey(event.nativeEvent))
return
if (event.key === 'Enter') {
event.preventDefault()
void finish(true)
}
// 已经发出的保存撤不回来,等它的结果。
else if (event.key === 'Escape' && state.status !== 'saving') {
event.preventDefault()
done(false, true)
}
}}
/>
<SaveStatus status={state.status === 'saving' ? 'saving' : 'idle'} />
</div>
{state.error && <p id={errorId} className="settings-row-error text-ui-caption" role="alert">{state.error}</p>}
</>
)
}
function NameEditor({ name }: { name: string }) {
const [editing, setEditing] = useState(false)
const [notice, setNotice] = useState(0)
const pencilRef = useRef<HTMLButtonElement>(null)
const refocusRef = useRef(false)
useEffect(() => {
if (editing || !refocusRef.current)
return
refocusRef.current = false
pencilRef.current?.focus()
}, [editing])
useEffect(() => {
if (!notice)
return
const timer = setTimeout(setNotice, savedNoticeMs, 0)
return () => clearTimeout(timer)
}, [notice])
if (editing) {
return (
<NameInput
name={name}
onDone={(saved, refocus) => {
refocusRef.current = refocus
setEditing(false)
setNotice(count => saved ? count + 1 : 0)
}}
/>
)
}
return (
<div className="settings-name">
{/* 点名字也能编辑;键盘用旁边的铅笔按钮。 */}
<span className="settings-name-text" onClick={() => setEditing(true)}>{name}</span>
<button ref={pencilRef} type="button" className="icon-button settings-name-edit" aria-label="编辑昵称" title="编辑昵称" onClick={() => setEditing(true)}>
<Pencil size={15} strokeWidth={1.9} aria-hidden="true" />
</button>
<SaveStatus status={notice ? 'saved' : 'idle'} version={notice} />
</div>
)
}
function IdentityCard({ member, account }: { member: Member, account: Account | null }) {
return (
<div className="settings-identity">
{/* 有头像时用它模糊后的样子做光晕;没有头像时这一层只有首字,由 CSS 隐藏。 */}
<Avatar hash={member.avatarHash} name={member.name} className="settings-identity-glow" />
<Avatar hash={member.avatarHash} name={member.name} className="settings-identity-avatar" />
<div className="settings-identity-copy">
<NameEditor name={member.name} />
<p className="settings-identity-meta">{identityMeta(account)}</p>
{!account && <p className="settings-identity-meta">注册之后可以在其他设备上登录</p>}
{account?.github && <span className="settings-badge">GitHub 已绑定</span>}
</div>
</div>
)
}
function AccountSkeleton() {
return (
<div role="status" aria-label="正在加载">
<div className="settings-identity">
<span className="settings-identity-avatar settings-skeleton" />
<div className="settings-identity-copy">
<span className="settings-skeleton settings-skeleton-line" />
<span className="settings-skeleton settings-skeleton-line settings-skeleton-short" />
</div>
</div>
<p className="section-group-label"><span className="settings-skeleton settings-skeleton-label" /></p>
<div className="section-group settings-skeleton-group" />
</div>
)
}
export function AccountSettings({ site, onRetrySite }: { site: Site, onRetrySite: () => void }) {
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const email = useStore(profileStore, s => s.email)
const failed = useStore(profileStore, s => s.failed)
const [dialog, setDialog] = useState<Dialog>(() => takeCredentialsPrompt() ? 'credentials' : null)
const [busy, setBusy] = useState(false)
const close = () => setDialog(null)
const github = async (intent: GitHubIntent) => {
setBusy(true)
if (!await goToGitHub(intent))
setBusy(false)
}
const unbind = async (currentPassword: string): Promise<boolean> => {
if (!account)
return false
try {
await api.unbindGithub({ currentPassword })
profileStore.updateAccount({ ...account, github: false })
notificationStore.success('已解绑 GitHub')
close()
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '没有解绑,再试一次。' : errorMessage(error))
return false
}
}
const loadFailed = (!member && failed) || site.status === 'failed'
const retry = () => {
if (!member && failed)
void profileStore.load().catch(() => {})
if (site.status === 'failed')
onRetrySite()
}
let content
if (loadFailed) {
content = (
<EmptyState icon={CircleAlert} title="账号信息没有加载出来" action={<button type="button" className="button-secondary" onClick={retry}>重试</button>}>
可能是网络中断,检查网络后再试。
</EmptyState>
)
}
else if (!member || site.status !== 'ready') {
content = <AccountSkeleton />
}
else {
content = (
<>
<IdentityCard member={member} account={account} />
{!account && (
<div className="settings-guest-actions">
{site.registrationOpen && <button type="button" className="button-primary" onClick={() => setDialog('register')}>注册</button>}
<button type="button" className={site.registrationOpen ? 'button-secondary' : 'button-primary'} onClick={() => setDialog('login')}>登录</button>
{site.githubLogin && <button type="button" className="button-secondary" disabled={busy} onClick={() => void github('login')}>用 GitHub 登录</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('redeem')}>输入配对码</button>
</div>
)}
<p className="section-group-label">资料</p>
<div className="section-group">
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">邮箱</span>
<p className="section-row-description">{account ? '用来显示头像,各设备一致' : '用来显示头像,只保存在这台设备上'}</p>
</div>
<InlineTextField label="邮箱" type="email" value={email} placeholder="name@example.com" maxLength={320} onSave={value => profileStore.save({ email: value.trim() })} />
</div>
</div>
{account && (
<>
<p className="section-group-label">登录方式</p>
<div className="section-group">
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">{account.username ? '密码' : '用户名和密码'}</span>
<p className="section-row-description">{account.username ? '修改后,其他设备需要重新登录' : '设置之后可以用密码登录'}</p>
</div>
{account.username
? <button type="button" className="button-secondary settings-button" onClick={() => setDialog('password')}>修改</button>
: <button type="button" className="button-secondary settings-button" disabled={busy} onClick={() => void github('verify')}>设置</button>}
</div>
{site.githubLogin && (
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">GitHub</span>
<p className="section-row-description">
{!account.github ? '绑定之后可以用 GitHub 登录这个账号' : account.username ? '可以用 GitHub 登录这个账号' : '先设置用户名和密码,才能解绑'}
</p>
</div>
<div className="settings-row-control">
{account.github && <span className="settings-badge">已绑定</span>}
{account.github
? <button type="button" className="button-secondary settings-button" disabled={!account.username} onClick={() => setDialog('unbind')}>解绑</button>
: <button type="button" className="button-secondary settings-button" onClick={() => setDialog('bind')}>绑定</button>}
</div>
</div>
)}
</div>
<p className="section-group-label">设备</p>
<div className="section-group">
<button type="button" className="section-row" onClick={() => setDialog('pairing')}>
<span className="section-row-copy">
<span className="section-row-title">在新设备上登录</span>
<span className="section-row-description">显示配对码和二维码</span>
</span>
<ChevronRight className="settings-chevron" size={18} aria-hidden="true" />
</button>
<button type="button" className="section-row" onClick={() => setDialog('logout')}>
<span className="section-row-copy">
<span className="section-row-title settings-danger">退出登录</span>
</span>
</button>
</div>
</>
)}
</>
)
}
return (
<>
<h2 className="settings-title">账号</h2>
{content}
{(dialog === 'register' || dialog === 'login' || dialog === 'credentials') && <CredentialsDialog mode={dialog} onClose={close} />}
{dialog === 'password' && <PasswordDialog onClose={close} />}
{dialog === 'pairing' && <PairingDialog onClose={close} />}
{dialog === 'redeem' && (
<Modal title="输入配对码" onClose={close}>
<PairingCodeForm onSignedIn={switchIdentity} />
</Modal>
)}
{(dialog === 'bind' || dialog === 'unbind') && (
<CurrentPasswordDialog
title={dialog === 'bind' ? '绑定 GitHub' : '解绑 GitHub'}
action={dialog === 'bind' ? '去 GitHub 授权' : '解绑'}
onConfirm={dialog === 'bind' ? currentPassword => goToGitHub('bind', currentPassword) : unbind}
onClose={close}
/>
)}
{dialog === 'logout' && (
<ConfirmDialog
title="退出登录"
confirmLabel="退出登录"
danger
onConfirm={async () => {
await profileStore.logout()
}}
onClose={close}
>
退出之后,这台设备会换成新的游客身份。
</ConfirmDialog>
)}
</>
)
}
@@ -0,0 +1,94 @@
import type { KeyboardEvent } from 'react'
import type { Theme } from './appearance-store'
import { Check } from 'lucide-react'
import { useRef } from 'react'
import { rovingIndex } from '@/shared/lib/roving'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { SaveStatus, SegmentedControl } from '@/shared/ui'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { SettingRow } from './setting-row'
import { themeNames } from './settings-labels'
function ThemePicker() {
const saved = useStore(appearanceStore, s => s.theme)
const setting = useSettingSave(saved, (theme: Theme) => appearanceStore.update(theme, appearanceStore.state.fontSize))
const groupRef = useRef<HTMLDivElement>(null)
const selected = themes.indexOf(setting.value)
// 方向键在选项之间移动并选中,和原生的单选按钮一样。
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const next = rovingIndex(event.key, selected, themes.length)
if (next === null)
return
event.preventDefault()
groupRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next]?.focus()
setting.change(themes[next])
}
return (
<>
<div className="section-group-label settings-group-label">
<span id="settings-theme-label">主题</span>
<SaveStatus status={setting.state.status} version={setting.savedVersion} />
</div>
<div ref={groupRef} role="radiogroup" aria-labelledby="settings-theme-label" aria-describedby={setting.state.error ? 'settings-theme-error' : undefined} className="settings-themes" onKeyDown={onKeyDown}>
{themes.map((theme, index) => (
<button
type="button"
role="radio"
key={theme}
className="settings-theme"
data-theme-preview={theme}
aria-checked={index === selected}
tabIndex={index === selected ? 0 : -1}
onClick={() => setting.change(theme)}
>
<span className="settings-theme-preview" aria-hidden="true">
<i />
<i />
</span>
<span className="settings-theme-caption">
{themeNames[theme]}
{index === selected && <Check size={14} strokeWidth={2.4} aria-hidden="true" />}
</span>
</button>
))}
</div>
{setting.state.error && <p id="settings-theme-error" className="settings-row-error text-ui-caption" role="alert">{setting.state.error}</p>}
</>
)
}
function FontSizeRow() {
const saved = useStore(appearanceStore, s => String(s.fontSize))
const setting = useSettingSave(saved, (size: string) => appearanceStore.update(appearanceStore.state.theme, Number(size)))
return (
<SettingRow
id="settings-font-size"
title="房间里正在播放《晴天》"
description="预览会跟着字号变化,布局和间距不变"
error={setting.state.error}
status={<SaveStatus status={setting.state.status} version={setting.savedVersion} />}
labelled={false}
>
<SegmentedControl
label="界面字号"
value={setting.value}
options={fontSizes.map(size => ({ value: String(size), label: String(size) }))}
onChange={setting.change}
/>
</SettingRow>
)
}
export function AppearanceSettings() {
return (
<>
<h2 className="settings-title">外观</h2>
<ThemePicker />
<p className="section-group-label">界面字号</p>
<div className="section-group">
<FontSizeRow />
</div>
</>
)
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { notificationHint, notificationRefusal, sectionSummaries } from './settings-labels'
const defaults = { theme: 'dark', level: 'medium', volume: 75, muted: false, recordListens: true } as const
describe('sectionSummaries', () => {
it('手机端分类列表显示各分类的当前值', () => {
expect(sectionSummaries(defaults)).toEqual({ appearance: '深色', playback: '75%', performance: '平衡', privacy: '开' })
expect(sectionSummaries({ ...defaults, theme: 'system', level: 'off', recordListens: false })).toEqual({ appearance: '跟随系统', playback: '75%', performance: '极简', privacy: '关' })
})
it('静音时显示静音', () => {
expect(sectionSummaries({ ...defaults, muted: true }).playback).toBe('静音')
})
it('还没读到记录播放历史的设置时不显示', () => {
expect(sectionSummaries({ ...defaults, recordListens: null }).privacy).toBeUndefined()
})
})
describe('notificationHint', () => {
it('还没授权时提供授权按钮', () => {
expect(notificationHint('default')).toEqual({ description: '需要浏览器授权,现在还没授权', authorize: true })
})
it('已经授权时不提供授权按钮', () => {
expect(notificationHint('granted').authorize).toBe(false)
})
it('浏览器拒绝之后不能再申请,提示去网站设置里允许', () => {
expect(notificationHint('denied')).toEqual({ description: '浏览器不允许显示通知,要在浏览器的网站设置里允许', authorize: false })
})
})
describe('notificationRefusal', () => {
it('被拒绝之后说明里已经写了去哪里允许,只说结果', () => {
expect(notificationRefusal('denied')).toBe('浏览器拒绝了显示通知。')
})
it('提示被关掉或者被浏览器收起时,告诉用户去浏览器的网站设置里允许', () => {
expect(notificationRefusal('default')).toBe('浏览器没有授权,可以在浏览器的网站设置里允许通知。')
})
})
@@ -0,0 +1,44 @@
import type { Theme } from './appearance-store'
import type { PerformanceLevel } from './performance-store'
export const themeNames: Record<Theme, string> = { dark: '深色', light: '浅色', system: '跟随系统' }
export const levelNames: Record<PerformanceLevel, { name: string, description: string }> = {
high: { name: '高质量', description: '所有动画效果,适合高性能设备。' },
medium: { name: '平衡', description: '优化过的动画效果,推荐使用。' },
low: { name: '省电', description: '简化动画效果,适合移动设备。' },
off: { name: '极简', description: '关闭所有动画,对设备的负担最小。' },
}
export function sectionSummaries({ theme, level, volume, muted, recordListens }: {
theme: Theme
level: PerformanceLevel
volume: number
muted: boolean
recordListens: boolean | null
}): { appearance: string, playback: string, performance: string, privacy?: string } {
return {
appearance: themeNames[theme],
playback: muted ? '静音' : `${volume}%`,
performance: levelNames[level].name,
...recordListens !== null && { privacy: recordListens ? '开' : '关' },
}
}
// 浏览器拒绝之后,网页不能再弹出授权请求。
export function notificationHint(permission: NotificationPermission): { description: string, authorize: boolean } {
switch (permission) {
case 'granted':
return { description: '轮到你点的歌时,在系统通知里提醒', authorize: false }
case 'denied':
return { description: '浏览器不允许显示通知,要在浏览器的网站设置里允许', authorize: false }
default:
return { description: '需要浏览器授权,现在还没授权', authorize: true }
}
}
// 申请没有拿到授权时的说明。被拒绝之后 notificationHint 已经写了去哪里允许;提示被关掉或者被浏览器收起时,权限仍是 default,
// 再申请可能不会弹出提示,所以也指向网站设置。
export function notificationRefusal(permission: NotificationPermission): string {
return permission === 'denied' ? '浏览器拒绝了显示通知。' : '浏览器没有授权,可以在浏览器的网站设置里允许通知。'
}
+52 -281
View File
@@ -1,292 +1,63 @@
import type { ReactNode } from 'react'
import type { Theme } from './appearance-store'
import type { PerformanceLevel } from './performance-store'
import { useEffect, useRef, useState } from 'react'
import { errorMessage } from '@/core/errors'
import { historyStore, searchLimits } from '@/features/history'
import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback'
import type { SectionItem } from '@/shared/ui'
import { Contrast, Gauge, Music, Shield, UserRound } from 'lucide-react'
import { useEffect } from 'react'
import { volumeStore } from '@/features/playback'
import { useStore } from '@/shared/lib/use-store'
import { notificationStore, Select } from '@/shared/ui'
import { AccountRow } from './account-section'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { performanceLevels, performanceStore } from './performance-store'
import { SectionLayout } from '@/shared/ui'
import { appearanceStore } from './appearance-store'
import { performanceStore } from './performance-store'
import { profileStore } from './profile-store'
import { VolumeInput } from './volume-input'
import { AccountSettings, IdentityEntry } from './settings-account'
import { AppearanceSettings } from './settings-appearance'
import { sectionSummaries } from './settings-labels'
import { PerformanceSettings } from './settings-performance'
import { PlaybackSettings } from './settings-playback'
import { PrivacySettings } from './settings-privacy'
import { useSite } from './use-site'
import './settings.css'
interface SettingsDraft {
name: string
email: string
theme: Theme
fontSize: number
volume: number
muted: boolean
reminderEnabled: boolean
systemNotifications: boolean
level: PerformanceLevel
reducedMotion: boolean
searchesEnabled: boolean
searchLimit: number
recordListens: boolean
}
export type SettingsSection = 'account' | 'appearance' | 'playback' | 'performance' | 'privacy'
type DraftKey = keyof SettingsDraft
const themeNames: Record<Theme, string> = { dark: '深色', light: '浅色', system: '跟随系统' }
const levelNames: Record<PerformanceLevel, { name: string, description: string }> = {
high: { name: '高质量', description: '所有动画效果,适合高性能设备。' },
medium: { name: '平衡', description: '优化过的动画效果,推荐使用。' },
low: { name: '省电', description: '简化动画效果,适合移动设备。' },
off: { name: '极简', description: '关闭所有动画,对设备的负担最小。' },
}
function loadProfile(): void {
void profileStore.load().catch((error: unknown) => {
notificationStore.error(errorMessage(error))
})
}
function SettingRow({ id, title, description, field = false, children }: {
id: string
title: string
description: string
field?: boolean
children: ReactNode
// section 为 null 时地址是 /settings:宽屏显示账号,窄屏显示分类列表。onSection(null) 回到分类列表。
export function SettingsPage({ section, onSection, onOpenHistory }: {
section: SettingsSection | null
onSection: (section: SettingsSection | null) => void
onOpenHistory: () => void
}) {
return (
<div className={`settings-row${field ? ' settings-row-field' : ''}`}>
<div className="settings-row-copy">
<label htmlFor={id} className="settings-row-title text-ui-base">{title}</label>
<p id={`${id}-description`} className="text-ui-caption muted">{description}</p>
</div>
<div className="settings-row-control">{children}</div>
</div>
)
}
export function SettingsPage() {
const headingRef = useRef<HTMLHeadingElement>(null)
const member = useStore(profileStore, s => s.member)
const email = useStore(profileStore, s => s.email)
const account = useStore(profileStore, s => s.account)
const recordListens = useStore(profileStore, s => s.recordListens)
const profileFailed = useStore(profileStore, s => s.failed)
const theme = useStore(appearanceStore, s => s.theme)
const fontSize = useStore(appearanceStore, s => s.fontSize)
const volume = useStore(volumeStore, s => s.volume)
const muted = useStore(volumeStore, s => s.muted)
const reminderEnabled = useStore(reminderStore, s => s.enabled)
const systemNotifications = useStore(reminderStore, s => s.systemNotifications)
const level = useStore(performanceStore, s => s.level)
const reducedMotion = useStore(performanceStore, s => s.reducedMotion)
const searchesEnabled = useStore(historyStore, s => s.searchesEnabled)
const searchLimit = useStore(historyStore, s => s.searchLimit)
const [changes, setChanges] = useState<Partial<SettingsDraft>>({})
const [saving, setSaving] = useState(false)
// state 要到下一次渲染才更新,连续提交时由这个 ref 阻止重复保存。
const savingRef = useRef(false)
const { site, reload: reloadSite } = useSite()
const summaries = sectionSummaries({
theme: useStore(appearanceStore, s => s.theme),
level: useStore(performanceStore, s => s.level),
volume: useStore(volumeStore, s => s.volume),
muted: useStore(volumeStore, s => s.muted),
recordListens: useStore(profileStore, s => s.recordListens),
})
// 资料的读取失败由各分类就地显示。
useEffect(() => {
headingRef.current?.focus({ preventScroll: true })
loadProfile()
void profileStore.load().catch(() => {})
}, [])
const current: SettingsDraft = { name: member?.name ?? '', email, theme, fontSize, volume, muted, reminderEnabled, systemNotifications, level, reducedMotion, searchesEnabled, searchLimit, recordListens: recordListens ?? false }
const draft: SettingsDraft = { ...current, ...changes }
const changed = (keys: DraftKey[]) => keys.some(key => draft[key] !== current[key])
const dirty = changed(Object.keys(changes) as DraftKey[])
const update = <K extends DraftKey>(key: K, value: SettingsDraft[K]) => {
setChanges(previous => ({ ...previous, [key]: value }))
}
const toggleSystemNotifications = (enabled: boolean) => {
if (!enabled) {
update('systemNotifications', false)
return
}
void requestNotificationPermission().then((allowed) => {
if (allowed)
update('systemNotifications', true)
else
notificationStore.error('浏览器没有允许显示通知,可以在浏览器的网站设置里允许。')
})
}
const save = async () => {
if (savingRef.current || !dirty)
return
savingRef.current = true
setSaving(true)
const saved: DraftKey[] = []
let failed = false
try {
if (changed(['name', 'email'])) {
// 只发送改动过的字段:空邮箱会清除头像,本机没有保存邮箱时不能一并发送。
const profile: { name?: string, email?: string } = {}
if (changed(['name']))
profile.name = draft.name.trim()
if (changed(['email']))
profile.email = draft.email.trim()
if (await profileStore.save(profile))
saved.push('name', 'email')
else
failed = true
}
if (changed(['theme', 'fontSize'])) {
appearanceStore.update(draft.theme, draft.fontSize)
saved.push('theme', 'fontSize')
}
if (changed(['volume', 'muted'])) {
volumeStore.setVolume(draft.volume)
volumeStore.setMuted(draft.muted)
saved.push('volume', 'muted')
}
if (changed(['reminderEnabled', 'systemNotifications'])) {
reminderStore.updateSettings({ enabled: draft.reminderEnabled, systemNotifications: draft.systemNotifications })
saved.push('reminderEnabled', 'systemNotifications')
}
if (changed(['level', 'reducedMotion'])) {
performanceStore.update(draft.level, draft.reducedMotion)
saved.push('level', 'reducedMotion')
}
if (changed(['searchesEnabled', 'searchLimit'])) {
historyStore.updateSettings({ searchesEnabled: draft.searchesEnabled, searchLimit: draft.searchLimit })
saved.push('searchesEnabled', 'searchLimit')
}
if (changed(['recordListens'])) {
if (await profileStore.setRecordListens(draft.recordListens))
saved.push('recordListens')
else
failed = true
}
setChanges((previous) => {
const rest = { ...previous }
for (const key of saved)
delete rest[key]
return rest
})
if (!failed)
notificationStore.success('已保存设置')
}
finally {
savingRef.current = false
setSaving(false)
}
}
const sections: SectionItem<SettingsSection>[] = [
{ id: 'account', label: '账号', icon: UserRound, hiddenInList: true },
{ id: 'appearance', label: '外观', icon: Contrast, summary: summaries.appearance },
{ id: 'playback', label: '播放与提醒', icon: Music, summary: summaries.playback },
{ id: 'performance', label: '性能', icon: Gauge, summary: summaries.performance },
{ id: 'privacy', label: '隐私与记录', icon: Shield, summary: summaries.privacy },
]
const shown = section ?? 'account'
return (
<section className="settings-page" aria-labelledby="settings-title">
<header className="settings-page-header">
<h1 id="settings-title" className="text-ui-xl" tabIndex={-1} ref={headingRef}>设置</h1>
</header>
<form
className="settings-page-form"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="settings-page-body">
<fieldset className="settings-fields" disabled={saving}>
<div className="settings-content">
<section className="settings-section" aria-labelledby="settings-account-title">
<h2 id="settings-account-title" className="text-ui-lg">账号</h2>
<div className="settings-group">
<AccountRow />
<SettingRow id="settings-name" title="昵称" description={member ? '房间里的其他人看到的名字。' : profileFailed ? '暂时无法获取你的资料。' : '正在获取你的资料…'} field>
{!member && profileFailed
? <button type="button" className="button-secondary" aria-describedby="settings-name-description" onClick={loadProfile}>重试</button>
: <input id="settings-name" aria-describedby="settings-name-description" value={draft.name} onChange={event => update('name', event.target.value)} disabled={!member} required maxLength={80} autoComplete="nickname" />}
</SettingRow>
<SettingRow id="settings-email" title="邮箱" description={account ? '保存在账号里,用来显示头像。留空就不显示。' : '只保存在这台设备上,用来显示头像。留空就不显示。'} field>
<input id="settings-email" aria-describedby="settings-email-description" type="email" value={draft.email} onChange={event => update('email', event.target.value)} disabled={!member} maxLength={320} autoComplete="email" placeholder="name@example.com" />
</SettingRow>
</div>
</section>
<section className="settings-section" aria-labelledby="settings-appearance-title">
<h2 id="settings-appearance-title" className="text-ui-lg">外观</h2>
<div className="settings-group">
<SettingRow id="settings-theme" title="主题" description="选择界面的明暗外观。">
<Select
id="settings-theme"
ariaDescribedBy="settings-theme-description"
value={draft.theme}
onChange={value => update('theme', themes.find(theme => theme === value) ?? draft.theme)}
options={themes.map(value => ({ value, label: themeNames[value] }))}
/>
</SettingRow>
<SettingRow id="settings-font-size" title="界面字号" description="调整文字大小,布局和间距保持不变。">
<Select
id="settings-font-size"
ariaDescribedBy="settings-font-size-description"
value={String(draft.fontSize)}
onChange={value => update('fontSize', Number(value))}
options={fontSizes.map(size => ({ value: String(size), label: `${size} px` }))}
/>
</SettingRow>
</div>
</section>
<section className="settings-section" aria-labelledby="settings-playback-title">
<h2 id="settings-playback-title" className="text-ui-lg">播放</h2>
<div className="settings-group">
<SettingRow id="settings-volume" title="音量" description="只调整这台设备的播放音量。" field>
<VolumeInput id="settings-volume" className="settings-volume-control" volume={draft.volume} onCommit={next => update('volume', next)} />
</SettingRow>
<SettingRow id="settings-muted" title="静音" description="暂时关闭这台设备的声音。">
<input id="settings-muted" aria-describedby="settings-muted-description" className="settings-switch" type="checkbox" role="switch" checked={draft.muted} onChange={event => update('muted', event.target.checked)} />
</SettingRow>
<SettingRow id="settings-reminder" title="自己点的歌开播时提醒" description="轮到你点的歌时,在页面上显示一条通知。">
<input id="settings-reminder" aria-describedby="settings-reminder-description" className="settings-switch" type="checkbox" role="switch" checked={draft.reminderEnabled} onChange={event => update('reminderEnabled', event.target.checked)} />
</SettingRow>
{systemNotificationsSupported() && (
<SettingRow id="settings-system-notifications" title="页面在后台时,用系统通知提醒" description={systemNotifications && Notification.permission !== 'granted' ? '浏览器现在不允许显示通知,关掉再打开可以重新申请。' : '轮到你点的歌时,在这台设备上显示系统通知。'}>
<input id="settings-system-notifications" aria-describedby="settings-system-notifications-description" className="settings-switch" type="checkbox" role="switch" checked={draft.systemNotifications} disabled={!draft.reminderEnabled} onChange={event => toggleSystemNotifications(event.target.checked)} />
</SettingRow>
)}
</div>
</section>
<section className="settings-section" aria-labelledby="settings-performance-title">
<h2 id="settings-performance-title" className="text-ui-lg">性能</h2>
<div className="settings-group">
<SettingRow id="settings-performance" title="性能模式" description={levelNames[draft.level].description}>
<Select
id="settings-performance"
ariaDescribedBy="settings-performance-description"
value={draft.level}
onChange={value => update('level', performanceLevels.find(level => level === value) ?? draft.level)}
options={performanceLevels.map(value => ({ value, label: levelNames[value].name }))}
/>
</SettingRow>
<SettingRow id="settings-reduced-motion" title="减少动态效果" description="关闭界面动画和歌词的平滑滚动。">
<input id="settings-reduced-motion" aria-describedby="settings-reduced-motion-description" className="settings-switch" type="checkbox" role="switch" checked={draft.reducedMotion} onChange={event => update('reducedMotion', event.target.checked)} />
</SettingRow>
</div>
</section>
<section className="settings-section" aria-labelledby="settings-history-title">
<h2 id="settings-history-title" className="text-ui-lg">历史记录</h2>
<div className="settings-group">
<SettingRow id="settings-search-history" title="保存搜索记录" description="在这台设备上保留最近搜索的内容。">
<input id="settings-search-history" aria-describedby="settings-search-history-description" className="settings-switch" type="checkbox" role="switch" checked={draft.searchesEnabled} onChange={event => update('searchesEnabled', event.target.checked)} />
</SettingRow>
<SettingRow id="settings-search-limit" title="搜索记录数量" description="最多保留的搜索记录条数。">
<Select
id="settings-search-limit"
ariaDescribedBy="settings-search-limit-description"
value={String(draft.searchLimit)}
disabled={!draft.searchesEnabled}
onChange={value => update('searchLimit', Number(value))}
options={searchLimits.map(limit => ({ value: String(limit), label: `${limit} 条` }))}
/>
</SettingRow>
<SettingRow id="settings-play-history" title="记录播放历史" description={recordListens === null && profileFailed ? '暂时无法获取这项设置。' : '把你在房间里听过的歌记进播放历史。'}>
<input id="settings-play-history" aria-describedby="settings-play-history-description" className="settings-switch" type="checkbox" role="switch" checked={draft.recordListens} disabled={recordListens === null} onChange={event => update('recordListens', event.target.checked)} />
</SettingRow>
</div>
</section>
</div>
</fieldset>
</div>
<footer className="settings-page-footer">
<p className="settings-save-status text-ui-caption muted" role="status">{dirty ? '有未保存的更改' : '所有更改已保存'}</p>
<button type="submit" className="button-primary" disabled={!dirty || saving} aria-busy={saving}>{saving ? '正在保存…' : '保存'}</button>
</footer>
</form>
</section>
<SectionLayout
title="设置"
sections={sections}
current={section}
defaultSection="account"
onSelect={onSection}
header={<IdentityEntry onOpen={() => onSection('account')} />}
>
{shown === 'account' && <AccountSettings site={site} onRetrySite={reloadSite} />}
{shown === 'appearance' && <AppearanceSettings />}
{shown === 'playback' && <PlaybackSettings />}
{shown === 'performance' && <PerformanceSettings />}
{shown === 'privacy' && <PrivacySettings onOpenHistory={onOpenHistory} />}
</SectionLayout>
)
}
@@ -0,0 +1,42 @@
import type { PerformanceLevel } from './performance-store'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { SaveStatus, SegmentedControl } from '@/shared/ui'
import { performanceLevels, performanceStore } from './performance-store'
import { SettingRow, SwitchRow } from './setting-row'
import { levelNames } from './settings-labels'
function LevelRow() {
const saved = useStore(performanceStore, s => s.level)
const setting = useSettingSave(saved, (level: PerformanceLevel) => performanceStore.update(level, performanceStore.state.reducedMotion))
const { name, description } = levelNames[setting.value]
return (
<SettingRow id="settings-performance" title="性能模式" description={`${name}:${description}`} error={setting.state.error} status={<SaveStatus status={setting.state.status} version={setting.savedVersion} />} labelled={false}>
<SegmentedControl
label="性能模式"
value={setting.value}
options={performanceLevels.map(level => ({ value: level, label: levelNames[level].name }))}
onChange={setting.change}
/>
</SettingRow>
)
}
export function PerformanceSettings() {
const reducedMotion = useStore(performanceStore, s => s.reducedMotion)
return (
<>
<h2 className="settings-title">性能</h2>
<div className="section-group">
<LevelRow />
<SwitchRow
id="settings-reduced-motion"
title="减少动态效果"
description="关闭界面动画和歌词的平滑滚动"
checked={reducedMotion}
onSave={on => performanceStore.update(performanceStore.state.level, on)}
/>
</div>
</>
)
}
@@ -0,0 +1,78 @@
import { Volume2, VolumeX } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { reminderStore, systemNotificationsSupported, volumeStore } from '@/features/playback'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { SaveStatus } from '@/shared/ui'
import { NotificationPermissionRequest } from './notification-permission'
import { SettingRow, SettingSwitch, SwitchRow } from './setting-row'
import { notificationHint, notificationRefusal } from './settings-labels'
import { VolumeInput } from './volume-input'
function SystemNotificationsRow() {
const reminderEnabled = useStore(reminderStore, s => s.enabled)
const saved = useStore(reminderStore, s => s.systemNotifications)
const setting = useSettingSave(saved, (on: boolean) => reminderStore.updateSettings({ enabled: reminderStore.state.enabled, systemNotifications: on }))
const [permissionRequest] = useState(() => new NotificationPermissionRequest())
const { permission, waiting, refused } = useStore(permissionRequest, s => s)
const hint = notificationHint(permission)
const error = refused ? notificationRefusal(permission) : setting.state.error
const id = 'settings-system-notifications'
// 浏览器答复之后“授权”按钮不再显示,焦点要是在它上面就掉到了 body:交给同一行的开关。
const authorizeShownRef = useRef(hint.authorize)
useEffect(() => {
const shown = authorizeShownRef.current
authorizeShownRef.current = hint.authorize
if (shown && !hint.authorize && document.activeElement === document.body)
document.getElementById(id)?.focus()
}, [hint.authorize])
// 打开之前先拿到授权,申请要在点击的同一个事件里发起。等待授权时开关显示为开,再点一次就是放弃。
const change = async (on: boolean) => {
if (!on) {
permissionRequest.abandon()
setting.change(false)
return
}
if (Notification.permission === 'granted' || await permissionRequest.request())
setting.change(true)
}
return (
<SettingRow
id={id}
title="页面在后台时用系统通知"
description={hint.description}
error={error}
status={<SaveStatus status={waiting ? 'saving' : setting.state.status} version={setting.savedVersion} savingText="等待浏览器授权…" />}
>
{hint.authorize && <button type="button" className="button-secondary settings-button" disabled={!reminderEnabled} onClick={() => void change(true)}>授权</button>}
<SettingSwitch id={id} checked={waiting || setting.value} error={error} disabled={!reminderEnabled} onChange={on => void change(on)} />
</SettingRow>
)
}
export function PlaybackSettings() {
const volume = useStore(volumeStore, s => s.volume)
const muted = useStore(volumeStore, s => s.muted)
const reminderEnabled = useStore(reminderStore, s => s.enabled)
const VolumeIcon = muted || volume === 0 ? VolumeX : Volume2
return (
<>
<h2 className="settings-title">播放与提醒</h2>
<div className="section-group">
<SettingRow id="settings-volume" title="音量" description="只影响这台设备">
<VolumeIcon className="settings-volume-icon" size={18} strokeWidth={1.8} aria-hidden="true" />
<VolumeInput id="settings-volume" className="settings-volume-control" volume={volume} onCommit={next => volumeStore.setVolume(next)} />
</SettingRow>
<SwitchRow id="settings-muted" title="静音" description="暂时关闭这台设备的声音" checked={muted} onSave={on => volumeStore.setMuted(on)} />
<SwitchRow
id="settings-reminder"
title="自己点的歌开播时提醒"
description="在页面上显示一条通知"
checked={reminderEnabled}
onSave={on => reminderStore.updateSettings({ enabled: on, systemNotifications: reminderStore.state.systemNotifications })}
/>
{systemNotificationsSupported() && <SystemNotificationsRow />}
</div>
</>
)
}
@@ -0,0 +1,73 @@
import { ChevronRight } from 'lucide-react'
import { historyStore, searchLimits } from '@/features/history'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { SaveStatus, SegmentedControl } from '@/shared/ui'
import { profileStore } from './profile-store'
import { SettingRow, SwitchRow } from './setting-row'
function RecordListensRow() {
const recordListens = useStore(profileStore, s => s.recordListens)
const failed = useStore(profileStore, s => s.failed)
const account = useStore(profileStore, s => s.account)
const id = 'settings-record-listens'
if (recordListens === null) {
return (
<SettingRow id={id} title="记录播放历史" description={failed ? '没有读到这项设置' : '正在读取…'} labelled={false}>
{failed
? <button type="button" className="button-secondary settings-button" onClick={() => void profileStore.load().catch(() => {})}>重试</button>
: <span className="settings-switch-placeholder" aria-hidden="true" />}
</SettingRow>
)
}
return (
<SwitchRow
id={id}
title="记录播放历史"
description={account ? '保存在服务器上,登录这个账号的设备共用' : '保存在服务器上'}
checked={recordListens}
onSave={on => profileStore.setRecordListens(on)}
/>
)
}
function SearchLimitRow() {
const saved = useStore(historyStore, s => String(s.searchLimit))
const setting = useSettingSave(saved, (limit: string) => historyStore.updateSettings({ searchesEnabled: historyStore.state.searchesEnabled, searchLimit: Number(limit) }))
return (
<SettingRow id="settings-search-limit" title="搜索记录数量" error={setting.state.error} status={<SaveStatus status={setting.state.status} version={setting.savedVersion} />} labelled={false}>
<SegmentedControl
label="搜索记录数量"
value={setting.value}
options={searchLimits.map(limit => ({ value: String(limit), label: String(limit) }))}
onChange={setting.change}
/>
</SettingRow>
)
}
export function PrivacySettings({ onOpenHistory }: { onOpenHistory: () => void }) {
const searchesEnabled = useStore(historyStore, s => s.searchesEnabled)
return (
<>
<h2 className="settings-title">隐私与记录</h2>
<div className="section-group">
<RecordListensRow />
<button type="button" className="section-row" onClick={onOpenHistory}>
<span className="section-row-copy">
<span className="section-row-title">查看播放历史</span>
</span>
<ChevronRight className="settings-chevron" size={18} aria-hidden="true" />
</button>
<SwitchRow
id="settings-search-history"
title="保存搜索记录"
description="只保存在这台设备上"
checked={searchesEnabled}
onSave={on => historyStore.updateSettings({ searchesEnabled: on, searchLimit: historyStore.state.searchLimit })}
/>
{searchesEnabled && <SearchLimitRow />}
</div>
</>
)
}
+93 -49
View File
@@ -1,54 +1,98 @@
.settings-page { container: settings / inline-size; display: flex; flex-direction: column; height: 100%; min-height: 0; }
.settings-page-header { flex-shrink: 0; width: 100%; max-width: 1120px; margin: 0 auto; padding: 28px 40px 20px; }
.settings-page-header h1 { outline: none; }
.settings-page-form { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.settings-page-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 16px 40px 48px; }
.settings-fields { min-width: 0; margin: 0; padding: 0; border: 0; }
.settings-content { display: flex; flex-direction: column; gap: 40px; max-width: 1040px; margin: 0 auto; }
.settings-section { min-width: 0; }
.settings-section h2 { margin-bottom: 16px; }
.settings-group { padding: 0 20px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-surface); }
.settings-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 24px; min-height: 86px; padding: 18px 0; }
.settings-row + .settings-row { border-top: 1px solid var(--color-border); }
.settings-row-copy { min-width: 0; }
.settings-row-title { display: block; font-weight: 600; }
.settings-row-copy p { margin-top: 4px; overflow-wrap: anywhere; }
.settings-row-control { display: flex; justify-content: flex-end; align-items: center; min-width: 0; max-width: 320px; }
.settings-row-control .select { width: auto; max-width: 100%; min-width: 128px; }
.settings-row-control .select-trigger { padding: 8px 12px; }
.settings-row-control .select-trigger:disabled, .settings-row-control input:disabled { opacity: .5; cursor: not-allowed; }
.settings-row-field .settings-row-control { width: 280px; }
.settings-volume-control { display: flex; align-items: center; gap: 16px; width: 100%; }
.settings-volume-control .volume-input-control { display: flex; align-items: center; gap: 4px; }
.settings-volume-control .volume-input { -moz-appearance: textfield; }
.settings-title { margin-bottom: 20px; font-size: var(--text-ui-lg); font-weight: 700; line-height: 1.3; }
.settings-title + .section-group-label { margin-top: 0; }
.settings-group-label { display: flex; align-items: center; gap: 10px; }
.settings-row-control { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; max-width: 100%; margin-left: auto; }
.settings-row-error { color: var(--color-destructive); overflow-wrap: anywhere; }
.section-row-copy > .settings-row-error { margin-top: 2px; }
.settings-themes + .settings-row-error { margin: 8px 4px 0; }
.settings-button { min-height: 32px; padding: 5px 14px; border-radius: 999px; font-size: var(--text-ui-caption); }
.settings-chevron { margin-left: auto; color: var(--color-foreground-subtlest); }
.settings-danger { color: var(--color-destructive); }
.settings-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; background: var(--color-card-hover); color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); white-space: nowrap; }
.settings-row-control > .settings-badge { background: var(--color-accent); color: var(--color-success); }
.settings-identity { --color-input: var(--color-input-on-card); position: relative; isolation: isolate; display: flex; align-items: center; gap: 18px; overflow: hidden; padding: 22px; border: 1px solid var(--color-border); border-radius: 16px; background: var(--color-card); }
.settings-identity-avatar { width: 64px; font-size: 24px; }
/* 光晕的中心对准头像:内边距 22px 加头像半径 32px。只是几乎察觉不到的氛围,浅色主题的 --glow-opacity 更低,在浅底上同样淡。 */
.settings-identity-glow { position: absolute; z-index: -1; top: 50%; left: 54px; width: 160px; background: transparent; box-shadow: none; color: transparent; opacity: calc(var(--glow-opacity) * .22); filter: blur(44px) saturate(1.3); transform: translate(-50%, -50%); pointer-events: none; }
.settings-identity-glow:not(:has(img)) { display: none; }
:root:is([data-performance='low'], [data-performance='off']) .settings-identity-glow { display: none; }
.settings-identity-copy { display: flex; flex: 1; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 0; }
.settings-identity-meta { color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); overflow-wrap: anywhere; }
.settings-identity .settings-badge { margin-top: 6px; }
.settings-name { display: flex; align-items: center; gap: 6px; max-width: 100%; min-width: 0; }
.settings-name-text { min-width: 0; overflow-wrap: anywhere; font-size: var(--text-ui-lg); font-weight: 700; line-height: 1.3; cursor: text; }
.settings-name-edit { width: 28px; height: 28px; border-radius: 50%; color: var(--color-foreground-subtlest); }
.settings-name-edit:hover:not(:disabled) { background: var(--color-surface-hover); color: var(--color-foreground); }
.settings-name-edit:focus-visible { outline-offset: 0; }
/* 输入框的文字和平时的名字对齐:负的外边距抵消边框和内边距。 */
input.settings-name-input { width: min(100%, 280px); margin: -5px 0 -5px -9px; padding: 4px 8px; font-size: var(--text-ui-lg); font-weight: 700; line-height: 1.3; }
input.settings-name-input[aria-invalid='true']:not(:focus) { border-color: var(--color-destructive); }
.settings-identity-copy > .settings-row-error { margin-top: 4px; }
.settings-guest-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.settings-guest-actions > button { border-radius: 999px; }
.settings-identity-entry { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 14px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-card); color: var(--color-foreground); text-align: left; }
.settings-identity-entry:hover { background: var(--color-card-hover); }
.settings-identity-entry:active:not(:disabled) { transform: none; background: var(--color-hover); }
.settings-identity-entry:focus-visible { outline-offset: -2px; }
.settings-identity-entry-avatar { width: 42px; font-size: 18px; }
.settings-identity-entry-copy { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; }
.settings-identity-entry-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.settings-identity-entry-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
.settings-skeleton { display: block; flex-shrink: 0; border-radius: 6px; background: var(--color-surface-hover); }
:is(.settings-identity-avatar, .settings-identity-entry-avatar).settings-skeleton { aspect-ratio: 1; border-radius: 50%; }
.settings-skeleton-line { width: 140px; max-width: 100%; height: 16px; }
.settings-skeleton-short { width: 90px; height: 12px; }
.settings-skeleton-label { width: 48px; height: 12px; }
.settings-skeleton-group { height: 112px; }
.settings-switch-placeholder { flex-shrink: 0; width: 40px; height: 24px; border-radius: 999px; background: var(--color-surface-hover); }
.settings-themes { display: flex; gap: 12px; }
.settings-theme { display: flex; flex: 0 1 140px; flex-direction: column; gap: 8px; min-width: 0; padding: 8px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-card); color: var(--color-foreground); text-align: left; }
.settings-theme:hover { border-color: var(--color-border-hover); }
.settings-theme[aria-checked='true'] { border-color: var(--color-brand); box-shadow: inset 0 0 0 1px var(--color-brand); }
.settings-theme:focus-visible { outline-offset: 2px; }
.settings-theme-preview { display: flex; gap: 4px; height: 64px; padding: 6px; border-radius: 8px; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-foreground) 10%, transparent); }
.settings-theme-preview > i { border-radius: 3px; }
.settings-theme-preview > i:first-child { width: 22%; }
.settings-theme-preview > i:last-child { flex: 1; }
/* 缩略图画的是各主题本身的颜色,不随当前主题变化,所以写死 theme.css 里对应的值。 */
[data-theme-preview='dark'] .settings-theme-preview { background: #121212; }
[data-theme-preview='dark'] .settings-theme-preview > i:first-child { background: #232326; }
[data-theme-preview='dark'] .settings-theme-preview > i:last-child { background: #1c1c1e; }
[data-theme-preview='light'] .settings-theme-preview { background: #f3f3f6; }
[data-theme-preview='light'] .settings-theme-preview > i:first-child { background: #e8e8ed; }
[data-theme-preview='light'] .settings-theme-preview > i:last-child { background: #fff; }
/* 浅的一半用浅色主题的内容底色:用 #f3f3f6 会和浅色主题下的卡片融成一片,整张看起来只剩深的一半。 */
[data-theme-preview='system'] .settings-theme-preview { background: linear-gradient(90deg, #121212 50%, #fff 50%); }
[data-theme-preview='system'] .settings-theme-preview > i:first-child { background: #232326; }
.settings-theme-caption { display: flex; align-items: center; justify-content: space-between; gap: 4px; min-height: 18px; font-size: var(--text-ui-caption); }
.settings-theme-caption > svg { color: var(--color-brand); }
.settings-volume-icon { color: var(--color-foreground-subtle); }
.settings-volume-control { display: flex; align-items: center; gap: 10px; width: 220px; max-width: 100%; }
/* 浅的 accent 会让浏览器把未填充的一段画成近黑色,在浅色主题的卡片里很扎眼;brand 在浅色主题里是深绿,未填充的一段就是浅色。 */
.settings-volume-control .volume-slider { flex: 1; min-width: 0; accent-color: var(--color-brand); }
.settings-volume-control .volume-input-control { display: flex; align-items: center; }
/* 百分比平时像一段文字,悬停或聚焦时才显出输入框。 */
.settings-volume-control input.volume-input { box-sizing: border-box; width: 4.5ch; padding: 4px 2px; border-color: transparent; background: transparent; color: var(--color-foreground-subtle); text-align: right; font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; -moz-appearance: textfield; }
.settings-volume-control input.volume-input:hover { border-color: var(--color-input-border); }
.settings-volume-control .volume-input::-webkit-inner-spin-button, .settings-volume-control .volume-input::-webkit-outer-spin-button { margin: 0; -webkit-appearance: none; }
.settings-volume-control .volume-input { box-sizing: border-box; width: 5ch; padding: 6px 4px; text-align: center; font-variant-numeric: tabular-nums; }
.settings-volume-control .volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); }
.settings-switch[type='checkbox'] { appearance: none; position: relative; width: 40px; height: 24px; margin: 0; border: 1px solid var(--color-border-hover); border-radius: 999px; background: var(--color-selected); cursor: pointer; }
.settings-switch[type='checkbox']::before { content: ''; display: block; width: 18px; height: 18px; margin: 2px; border-radius: 50%; background: var(--color-foreground); transform: translateX(0); transition: transform 150ms ease; }
.settings-switch[type='checkbox']:checked { border-color: var(--color-primary); background: var(--color-primary); }
.settings-switch[type='checkbox']:checked::before { background: var(--color-primary-foreground); transform: translateX(16px); }
:root[data-performance='low'] .settings-switch[type='checkbox']::before { transition: none; }
.settings-switch[type='checkbox']:focus-visible, .settings-row-control input[type='range']:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 4px; }
.settings-page-footer { display: flex; align-items: center; justify-content: flex-end; gap: 20px; flex-shrink: 0; width: 100%; max-width: 1120px; margin: 0 auto; padding: 16px 40px 24px; }
.settings-save-status { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.settings-page-footer .button-primary { min-width: 96px; }
.settings-account-actions { flex-wrap: wrap; gap: 8px; }
.settings-volume-control .volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
/* 窄的内容区里,换到下一行的分段控件和音量占满整行。 */
@container section-layout (max-width: 480px) {
.settings-row-control:has(> :is(.segmented-control, .settings-volume-control)) { flex: 1 1 100%; }
.settings-row-control > .segmented-control { flex: 1; }
.settings-identity { padding: 18px; gap: 14px; }
.settings-identity-glow { left: 46px; width: 128px; }
.settings-identity-avatar { width: 56px; font-size: 21px; }
.settings-volume-control { flex: 1; width: auto; }
}
.settings-pairing { display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; }
.settings-pairing-qr { width: 200px; height: 200px; padding: 8px; border-radius: 8px; background: #fff; image-rendering: pixelated; }
.settings-pairing-code { font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums; letter-spacing: .12em; user-select: all; }
.settings-pair-page { max-width: 480px; }
@container settings (max-width: 640px) {
.settings-page-header { padding: 20px 20px 16px; }
.settings-page-body { padding: 12px 16px 32px; }
.settings-content { gap: 28px; }
.settings-section h2 { margin-left: 4px; margin-bottom: 12px; }
.settings-group { padding: 0 16px; }
.settings-row { gap: 16px; min-height: 80px; padding: 16px 0; }
.settings-row-control { max-width: 160px; }
.settings-row-control .select { min-width: 100px; }
.settings-row-control .select-trigger { padding: 8px; }
.settings-row-field { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.settings-row-field .settings-row-control { width: 100%; max-width: none; }
.settings-page-footer { gap: 12px; padding: 12px 20px 16px; }
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
// 站点是否开放注册、是否开启 GitHub 登录。
export type Site = { status: 'loading' } | { status: 'failed' } | { status: 'ready', registrationOpen: boolean, githubLogin: boolean }
export function useSite(): { site: Site, reload: () => void } {
const [site, setSite] = useState<Site>({ status: 'loading' })
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let active = true
api.getSite().then(({ registrationOpen, githubLogin }) => {
if (active)
setSite({ status: 'ready', registrationOpen, githubLogin })
}, () => {
if (active)
setSite({ status: 'failed' })
})
return () => {
active = false
}
}, [attempt])
return {
site,
reload: () => {
setSite({ status: 'loading' })
setAttempt(count => count + 1)
},
}
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { isImeKey } from './ime'
describe('isImeKey', () => {
it('组字期间的按键交给输入法', () => {
expect(isImeKey({ isComposing: true, keyCode: 13 })).toBe(true)
})
it('在 Safari 里用回车确认候选词时,组字已经先结束,只剩 keyCode 229 能认出来', () => {
expect(isImeKey({ isComposing: false, keyCode: 229 })).toBe(true)
})
it('平时的回车和 Esc 交给输入框', () => {
expect(isImeKey({ isComposing: false, keyCode: 13 })).toBe(false)
expect(isImeKey({ isComposing: false, keyCode: 27 })).toBe(false)
})
})
+5
View File
@@ -0,0 +1,5 @@
// 这次按键属于输入法,比如用回车确认候选词、用 Esc 取消拼写,不是给输入框的。
// Safari 先结束组字再触发这次 keydown,isComposing 已经是 false,只能靠 keyCode 229 认出来。
export function isImeKey(event: Pick<KeyboardEvent, 'isComposing' | 'keyCode'>): boolean {
return event.isComposing || event.keyCode === 229
}
+74
View File
@@ -0,0 +1,74 @@
import type { InlineSaveState } from './inline-save'
import { describe, expect, it } from 'vitest'
import { inlineSave } from './inline-save'
const idle: InlineSaveState = { draft: '大猫', saved: '大猫', status: 'idle', error: null }
describe('inlineSave', () => {
it('edit 只改 draft', () => {
expect(inlineSave(idle, { type: 'edit', value: '小猫' })).toEqual({ ...idle, draft: '小猫' })
const failed: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }
expect(inlineSave(failed, { type: 'edit', value: '小狗' })).toEqual({ ...failed, draft: '小狗' })
})
it('submit 时 draft 没变则不进入 saving', () => {
expect(inlineSave(idle, { type: 'submit' })).toBe(idle)
const saved: InlineSaveState = { ...idle, status: 'saved' }
expect(inlineSave(saved, { type: 'submit' })).toBe(saved)
})
it('submit 时 draft 改过则进入 saving,并清掉上次的错误', () => {
expect(inlineSave({ ...idle, draft: '小猫' }, { type: 'submit' })).toEqual({ draft: '小猫', saved: '大猫', status: 'saving', error: null })
expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'submit' })).toEqual({ draft: '小猫', saved: '大猫', status: 'saving', error: null })
})
it('改回已保存的值再 submit 时清掉错误', () => {
expect(inlineSave({ draft: '大猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'submit' })).toEqual(idle)
})
it('saving 时忽略再次 submit', () => {
const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'submit' })).toBe(saving)
})
it('succeeded 后 saved = draft,status = saved', () => {
const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'succeeded', value: '小猫' })).toEqual({ draft: '小猫', saved: '小猫', status: 'saved', error: null })
})
it('保存期间又改了 draft 时,succeeded 保留新的 draft', () => {
const saving: InlineSaveState = { draft: '小猫咪', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'succeeded', value: '小猫' })).toEqual({ draft: '小猫咪', saved: '小猫', status: 'saved', error: null })
})
it('failed 后 status = error,保留 draft 和 error', () => {
const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'failed', error: '名字太长' })).toEqual({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' })
})
it('不在 saving 时忽略 succeeded 和 failed', () => {
expect(inlineSave(idle, { type: 'succeeded', value: '小猫' })).toBe(idle)
expect(inlineSave(idle, { type: 'failed', error: '名字太长' })).toBe(idle)
})
it('revert 把 draft 恢复为 saved,清掉 error', () => {
expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'revert' })).toEqual(idle)
expect(inlineSave({ ...idle, draft: '小猫' }, { type: 'revert' })).toEqual(idle)
})
it('saving 时忽略 revert', () => {
const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null }
expect(inlineSave(saving, { type: 'revert' })).toBe(saving)
})
it('external 在 idle 或 saved 时更新 saved 和 draft', () => {
expect(inlineSave(idle, { type: 'external', value: '老虎' })).toEqual({ ...idle, draft: '老虎', saved: '老虎' })
expect(inlineSave({ ...idle, status: 'saved' }, { type: 'external', value: '老虎' })).toEqual({ draft: '老虎', saved: '老虎', status: 'saved', error: null })
})
it('external 在 saving 或编辑中时只更新 saved', () => {
expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'saving', error: null }, { type: 'external', value: '老虎' })).toEqual({ draft: '小猫', saved: '老虎', status: 'saving', error: null })
expect(inlineSave({ ...idle, draft: '小猫' }, { type: 'external', value: '老虎' })).toEqual({ draft: '小猫', saved: '老虎', status: 'idle', error: null })
expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'external', value: '老虎' })).toEqual({ draft: '小猫', saved: '老虎', status: 'error', error: '名字太长' })
})
})
+45
View File
@@ -0,0 +1,45 @@
// saved 是已经保存的值,draft 是输入框里的值;两者不同就是“编辑中”。
export interface InlineSaveState {
draft: string
saved: string
status: 'idle' | 'saving' | 'saved' | 'error'
error: string | null
}
// succeeded 的 value 是提交的值;external 是外部传入的新值,比如其他设备改了资料。
export type InlineSaveEvent
= | { type: 'edit', value: string }
| { type: 'submit' }
| { type: 'succeeded', value: string }
| { type: 'failed', error: string }
| { type: 'revert' }
| { type: 'external', value: string }
export function inlineSave(state: InlineSaveState, event: InlineSaveEvent): InlineSaveState {
switch (event.type) {
case 'edit':
return { ...state, draft: event.value }
case 'submit':
if (state.status === 'saving')
return state
if (state.draft === state.saved)
return state.status === 'error' ? { ...state, status: 'idle', error: null } : state
return { ...state, status: 'saving', error: null }
case 'succeeded':
return state.status === 'saving' ? { ...state, saved: event.value, status: 'saved', error: null } : state
case 'failed':
return state.status === 'saving' ? { ...state, status: 'error', error: event.error } : state
case 'revert':
// 已经发出的保存撤不回来,等它的结果。
if (state.status === 'saving')
return state
return { ...state, draft: state.saved, status: 'idle', error: null }
case 'external':
if (event.value === state.saved)
return state
// 保存中或者编辑中时不覆盖输入框,免得冲掉用户正在输入的内容。
if (state.status === 'saving' || state.draft !== state.saved)
return { ...state, saved: event.value }
return { ...state, draft: event.value, saved: event.value }
}
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { pageItems } from './page-items'
describe('pageItems', () => {
it('页数不多时列出全部页码', () => {
expect(pageItems(1, 1)).toEqual([1])
expect(pageItems(1, 5)).toEqual([1, 2, 3, 4, 5])
expect(pageItems(4, 7)).toEqual([1, 2, 3, 4, 5, 6, 7])
})
it('页数多时只列出首页、末页和当前页前后各一页,其余用省略号', () => {
expect(pageItems(2, 26)).toEqual([1, 2, 3, 'gap', 26])
expect(pageItems(13, 26)).toEqual([1, 'gap', 12, 13, 14, 'gap', 26])
expect(pageItems(26, 26)).toEqual([1, 'gap', 25, 26])
})
it('省略号只盖住一页时直接显示那一页', () => {
expect(pageItems(4, 8)).toEqual([1, 2, 3, 4, 5, 'gap', 8])
expect(pageItems(5, 8)).toEqual([1, 'gap', 4, 5, 6, 7, 8])
})
})
+20
View File
@@ -0,0 +1,20 @@
// 页码条最多 7 项:首页、省略号、当前页和前后各一页、省略号、末页。不超过 7 页时全部列出;
// 省略号只盖住一页时,直接显示那一页,占的位置一样。
export function pageItems(current: number, totalPages: number): (number | 'gap')[] {
if (totalPages <= 7)
return Array.from({ length: totalPages }, (_, index) => index + 1)
const pages = [...new Set([1, current - 1, current, current + 1, totalPages])]
.filter(page => page >= 1 && page <= totalPages)
.sort((a, b) => a - b)
const items: (number | 'gap')[] = []
let previous = 0
for (const page of pages) {
if (page - previous === 2)
items.push(previous + 1)
else if (page - previous > 2)
items.push('gap')
items.push(page)
previous = page
}
return items
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { toggleSelection } from './range-selection'
const ids = ['a', 'b', 'c', 'd', 'e']
describe('toggleSelection', () => {
it('普通点击切换单个 id,并把它设为锚点', () => {
expect(toggleSelection(new Set(), ids, 'b', null, false)).toEqual({ selected: new Set(['b']), anchor: 'b' })
expect(toggleSelection(new Set(['b', 'c']), ids, 'b', 'c', false)).toEqual({ selected: new Set(['c']), anchor: 'b' })
})
it('按住 Shift 点击时选中锚点到当前 id 之间的全部,含两端,方向不限', () => {
expect(toggleSelection(new Set(['b']), ids, 'd', 'b', true)).toEqual({ selected: new Set(['b', 'c', 'd']), anchor: 'd' })
expect(toggleSelection(new Set(['d']), ids, 'a', 'd', true)).toEqual({ selected: new Set(['a', 'b', 'c', 'd']), anchor: 'a' })
})
it('按住 Shift 点击时保留范围之外已经选中的 id', () => {
expect(toggleSelection(new Set(['a', 'c']), ids, 'e', 'c', true)).toEqual({ selected: new Set(['a', 'c', 'd', 'e']), anchor: 'e' })
})
it('没有锚点时的 Shift 点击等同于普通点击', () => {
expect(toggleSelection(new Set(), ids, 'c', null, true)).toEqual({ selected: new Set(['c']), anchor: 'c' })
expect(toggleSelection(new Set(['c']), ids, 'c', null, true)).toEqual({ selected: new Set(), anchor: 'c' })
})
it('锚点不在当前列表里时的 Shift 点击等同于普通点击', () => {
expect(toggleSelection(new Set(), ids, 'c', 'z', true)).toEqual({ selected: new Set(['c']), anchor: 'c' })
})
it('不修改传入的集合', () => {
const selected = new Set(['a'])
toggleSelection(selected, ids, 'c', 'a', true)
expect(selected).toEqual(new Set(['a']))
})
})
+18
View File
@@ -0,0 +1,18 @@
// 锚点是上一次点击的 id。按住 Shift 点击时选中锚点到这一项之间的全部,不取消范围外已经选中的项;
// 没有锚点,或者锚点已经不在列表里(比如翻了页)时,和普通点击一样只切换这一项。
export function toggleSelection(selected: ReadonlySet<string>, ids: readonly string[], id: string, anchor: string | null, shift: boolean): { selected: Set<string>, anchor: string } {
const next = new Set(selected)
const from = anchor === null ? -1 : ids.indexOf(anchor)
const to = ids.indexOf(id)
if (shift && from !== -1 && to !== -1) {
for (const item of ids.slice(Math.min(from, to), Math.max(from, to) + 1))
next.add(item)
}
else if (next.has(id)) {
next.delete(id)
}
else {
next.add(id)
}
return { selected: next, anchor: id }
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { rowAfterRemoval } from './removal-focus'
describe('rowAfterRemoval', () => {
it('删掉一行之后落到原来的下一行', () => {
expect(rowAfterRemoval(['a', 'b', 'c', 'd'], ['a', 'c', 'd'], ['b'])).toBe('c')
})
it('删掉第一行之后落到新的第一行', () => {
expect(rowAfterRemoval(['a', 'b', 'c'], ['b', 'c'], ['a'])).toBe('b')
})
it('删掉这一页的最后一行、下一页的行补上来时,落到补上来的那一行', () => {
expect(rowAfterRemoval(['a', 'b', 'c'], ['a', 'b', 'x'], ['c'])).toBe('x')
})
it('删掉的是最后一行、后面没有行了,返回 null', () => {
expect(rowAfterRemoval(['a', 'b', 'c'], ['a', 'b'], ['c'])).toBeNull()
})
it('这一页全删空了,返回 null', () => {
expect(rowAfterRemoval(['a', 'b'], [], ['a', 'b'])).toBeNull()
})
it('一次删掉不相邻的几行,落到删掉的第一行原来的下一行', () => {
expect(rowAfterRemoval(['a', 'b', 'c', 'd', 'e'], ['a', 'c', 'e'], ['b', 'd'])).toBe('c')
})
it('一次删掉相邻的几行,落到它们后面的那一行', () => {
expect(rowAfterRemoval(['a', 'b', 'c', 'd'], ['a', 'd'], ['b', 'c'])).toBe('d')
})
it('要删的行有的没删掉(比如正在播放),从真正删掉的第一行算起', () => {
expect(rowAfterRemoval(['a', 'b', 'c', 'd'], ['a', 'b', 'd'], ['b', 'c'])).toBe('d')
})
it('一行也没删掉,返回 null', () => {
expect(rowAfterRemoval(['a', 'b'], ['a', 'b'], ['b'])).toBeNull()
})
it('前面插进了新的行也不影响', () => {
expect(rowAfterRemoval(['a', 'b', 'c'], ['n', 'a', 'c'], ['b'])).toBe('c')
})
})
+17
View File
@@ -0,0 +1,17 @@
// 删掉列表里的若干行之后焦点落到哪一行:真正删掉的第一行原来的位置上,现在排着的那一行(后面的行补上来,翻页补进来的也算)。
// before 和 after 是删除前后这一页各行的 key。那个位置上已经没有行时返回 null。
export function rowAfterRemoval(before: readonly string[], after: readonly string[], removed: readonly string[]): string | null {
const remaining = new Set(after)
const requested = new Set(removed)
const first = before.findIndex(key => requested.has(key) && !remaining.has(key))
if (first === -1)
return null
let index = 0
for (let i = first - 1; i >= 0; i--) {
if (remaining.has(before[i]!)) {
index = after.indexOf(before[i]!) + 1
break
}
}
return after[index] ?? null
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { rovingIndex } from './roving'
describe('rovingIndex', () => {
it('← 和 ↑ 去上一项,→ 和 ↓ 去下一项,到头之后从另一头接着走', () => {
expect(rovingIndex('ArrowLeft', 1, 3)).toBe(0)
expect(rovingIndex('ArrowUp', 1, 3)).toBe(0)
expect(rovingIndex('ArrowRight', 1, 3)).toBe(2)
expect(rovingIndex('ArrowDown', 1, 3)).toBe(2)
expect(rovingIndex('ArrowUp', 0, 3)).toBe(2)
expect(rovingIndex('ArrowDown', 2, 3)).toBe(0)
})
it('按 Home 和 End 去第一项和最后一项', () => {
expect(rovingIndex('Home', 2, 3)).toBe(0)
expect(rovingIndex('End', 0, 3)).toBe(2)
})
it('其他按键和空的组不处理', () => {
expect(rovingIndex('Enter', 1, 3)).toBeNull()
expect(rovingIndex(' ', 1, 3)).toBeNull()
expect(rovingIndex('ArrowDown', 0, 0)).toBeNull()
})
})
+19
View File
@@ -0,0 +1,19 @@
// 单选组里方向键要去的那一项,和原生的单选按钮一样:←/↑ 和 →/↓ 等价,到头之后从另一头接着走。不是这几个键时返回 null。
export function rovingIndex(key: string, index: number, count: number): number | null {
if (!count)
return null
switch (key) {
case 'ArrowLeft':
case 'ArrowUp':
return (index - 1 + count) % count
case 'ArrowRight':
case 'ArrowDown':
return (index + 1) % count
case 'Home':
return 0
case 'End':
return count - 1
default:
return null
}
}
+35 -7
View File
@@ -100,6 +100,12 @@ async function open(state: unknown = null) {
get href() {
return history.current.url
},
get pathname() {
return new URL(history.current.url, 'https://alisten.test').pathname
},
get search() {
return new URL(history.current.url, 'https://alisten.test').search
},
},
addEventListener: (type: string, listener: EventListener) => history.events.addEventListener(type, listener),
})
@@ -125,7 +131,7 @@ describe('打开页面', () => {
const { firstOpen, history, currentEntry } = await open({ index: 3, title: '房间' })
expect(firstOpen).toBe(false)
expect(history.state).toEqual({ index: 3, title: '房间' })
expect(currentEntry()).toEqual({ position: { index: 3, last: 3 }, title: '房间' })
expect(currentEntry()).toEqual({ position: { index: 3, last: 3 }, title: '房间', from: undefined })
})
it('刷新时弹窗已经关闭,标记记录改作页面的记录', async () => {
@@ -152,10 +158,10 @@ describe('写入记录', () => {
writeEntry('/a', false, '甲')
writeEntry('/b', false)
expect(history.urls).toEqual(['/start', '/a', '/b'])
expect(currentEntry()).toEqual({ position: { index: 2, last: 2 }, title: undefined })
expect(currentEntry()).toEqual({ position: { index: 2, last: 2 }, title: undefined, from: '/a' })
history.press()
expect(currentEntry()).toEqual({ position: { index: 1, last: 2 }, title: '甲' })
expect(currentEntry()).toEqual({ position: { index: 1, last: 2 }, title: '甲', from: '/start' })
expect(log).toEqual(['notify'])
})
@@ -165,7 +171,7 @@ describe('写入记录', () => {
expect(history.state).toEqual({ index: 1, title: '房间' })
writeEntry('/b', true, '搜索')
expect(history.urls).toEqual(['/b'])
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '搜索' })
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '搜索', from: undefined })
})
it('浏览器限制调用频率时地址暂时落后,序号不变,页面照常', async () => {
@@ -173,7 +179,7 @@ describe('写入记录', () => {
history.failure = new DOMException('Too many calls', 'SecurityError')
expect(() => writeEntry('/a', false)).not.toThrow()
expect(() => writeEntry('/b', true, '甲')).not.toThrow()
expect(currentEntry()).toEqual({ position: { index: 0, last: 0 }, title: undefined })
expect(currentEntry()).toEqual({ position: { index: 0, last: 0 }, title: undefined, from: undefined })
history.failure = null
writeEntry('/c', false)
@@ -190,6 +196,28 @@ describe('写入记录', () => {
expect(log).toEqual(['notify'])
})
it('新记录记下写入时的地址,也就是它前面那条记录的地址;替换时沿用,刷新之后还在', async () => {
const { writeEntry, currentEntry, history } = await open()
writeEntry('/settings', true)
writeEntry('/settings/appearance', false)
expect(currentEntry().from).toBe('/settings')
writeEntry('/admin/media?q=a', false)
writeEntry('/admin/media?q=ab', true)
expect(currentEntry().from).toBe('/settings/appearance')
expect(history.state).toEqual({ index: 2, from: '/settings/appearance' })
history.press()
expect(currentEntry().from).toBe('/settings')
})
it('刷新时沿用记录里的来处,别的脚本写入的来处不用', async () => {
expect((await open({ index: 2, from: '/settings' })).currentEntry().from).toBe('/settings')
const other = await open({ index: 2, from: 3 })
expect(other.currentEntry().from).toBeUndefined()
expect(other.history.state).toEqual({ index: 2, from: 3 })
expect((await open({ index: 2, from: '/settings', modal: true })).history.state).toEqual({ index: 2, from: '/settings' })
})
it('其他错误照常抛出', async () => {
const { writeEntry, history } = await open()
history.failure = new DOMException('bad', 'DataCloneError')
@@ -275,7 +303,7 @@ describe('弹窗', () => {
history.settle()
expect(history.urls).toEqual(['/start', '/next'])
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '下一页' })
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '下一页', from: '/start' })
})
it('弹窗开着时替换地址:先退到页面的记录再替换,然后压回标记', async () => {
@@ -296,7 +324,7 @@ describe('弹窗', () => {
registerModalHistory(() => {})
writeEntry('/room', false)
expect(history.urls).toEqual(['/start', '/room'])
expect(history.state).toEqual({ index: 1 })
expect(history.state).toEqual({ index: 1, from: '/start' })
expect(currentEntry().position).toEqual({ index: 1, last: 1 })
})
+12 -3
View File
@@ -27,6 +27,8 @@ export function canGoForward(at: Position): boolean {
interface EntryState {
index: number
title?: string
// 写入这条记录时所在的地址,也就是后退会回到的那条记录的地址。
from?: string
modal?: true
}
@@ -47,12 +49,14 @@ const queued: Write[] = []
function readEntry(state: unknown): EntryState | null {
if (typeof state !== 'object' || state === null)
return null
const { index, title, modal } = state as Record<string, unknown>
const { index, title, from, modal } = state as Record<string, unknown>
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0)
return null
const entry: EntryState = { index }
if (typeof title === 'string')
entry.title = title
if (typeof from === 'string')
entry.from = from
if (modal === true)
entry.modal = true
return entry
@@ -116,6 +120,8 @@ export function startHistory(onChange: () => void): boolean {
current = { index: entry?.index ?? 0 }
if (entry?.title !== undefined)
current.title = entry.title
if (entry?.from !== undefined)
current.from = entry.from
// 刷新时弹窗已经关闭,它留下的标记记录改作页面的记录。
if (!entry || entry.modal)
window.history.replaceState(current, '')
@@ -124,8 +130,8 @@ export function startHistory(onChange: () => void): boolean {
return entry === null
}
export function currentEntry(): { position: Position, title: string | undefined } {
return { position, title: current.title }
export function currentEntry(): { position: Position, title: string | undefined, from: string | undefined } {
return { position, title: current.title, from: current.from }
}
// 后退一个页面。弹窗的标记记录压在当前页面上(或者正在退掉它)时只退到页面的记录:落到那里时导航会收到通知,由它决定要不要接着后退。
@@ -153,6 +159,9 @@ export function writeEntry(url: string, replace: boolean, title?: string): void
const entryTitle = replace ? title ?? current.title : title
if (entryTitle !== undefined)
entry.title = entryTitle
const from = replace ? current.from : window.location.pathname + window.location.search
if (from !== undefined)
entry.from = from
// 在大厅的加入、创建房间弹窗里换页面时,弹窗随旧页面一起关闭,新页面的记录直接顶替标记记录。
const written = write(() => replace || current.modal
? window.history.replaceState(entry, '', url)
+47
View File
@@ -0,0 +1,47 @@
import type { SettingSave } from './setting-save'
import { describe, expect, it } from 'vitest'
import { idleSave, settingSave, shownValue } from './setting-save'
const saving: SettingSave<boolean> = { pending: { value: true }, status: 'saving', error: null }
describe('settingSave', () => {
it('开始保存时控件先显示新值', () => {
const state = settingSave<boolean>(idleSave, { type: 'start', value: true })
expect(state).toEqual(saving)
expect(shownValue(state, false)).toBe(true)
})
it('保存中不接受新的修改', () => {
expect(settingSave(saving, { type: 'start', value: false })).toBe(saving)
})
it('保存成功后控件跟随已保存的值', () => {
const state = settingSave(saving, { type: 'succeeded' })
expect(state).toEqual({ pending: null, status: 'saved', error: null })
expect(shownValue(state, true)).toBe(true)
})
it('保存失败时控件回到原值,保留原因', () => {
const state = settingSave(saving, { type: 'failed', error: '暂时不可用' })
expect(state).toEqual({ pending: null, status: 'failed', error: '暂时不可用' })
expect(shownValue(state, false)).toBe(false)
})
it('再次修改时清掉上次的原因', () => {
const failed = settingSave(saving, { type: 'failed', error: '暂时不可用' })
expect(settingSave(failed, { type: 'start', value: true })).toEqual(saving)
})
it('“已保存”到时消失,不影响保存中和失败', () => {
const saved = settingSave(saving, { type: 'succeeded' })
expect(settingSave(saved, { type: 'expired' })).toEqual(idleSave)
expect(settingSave(saving, { type: 'expired' })).toBe(saving)
const failed = settingSave(saving, { type: 'failed', error: '暂时不可用' })
expect(settingSave(failed, { type: 'expired' })).toBe(failed)
})
it('不在保存中时忽略保存的结果', () => {
expect(settingSave<boolean>(idleSave, { type: 'succeeded' })).toBe(idleSave)
expect(settingSave<boolean>(idleSave, { type: 'failed', error: '暂时不可用' })).toBe(idleSave)
})
})
+33
View File
@@ -0,0 +1,33 @@
// 改完即生效的控件(开关、分段选择)的保存状态。pending 是正在保存的新值,控件先显示它;保存失败时丢掉它,控件回到已保存的值。
export interface SettingSave<T> {
pending: { value: T } | null
status: 'idle' | 'saving' | 'saved' | 'failed'
error: string | null
}
// expired:“已保存”的提示到时消失。
export type SettingSaveEvent<T>
= | { type: 'start', value: T }
| { type: 'succeeded' }
| { type: 'failed', error: string }
| { type: 'expired' }
export const idleSave: SettingSave<never> = { pending: null, status: 'idle', error: null }
export function settingSave<T>(state: SettingSave<T>, event: SettingSaveEvent<T>): SettingSave<T> {
switch (event.type) {
case 'start':
// 一次只保存一个值:保存中再改,结果回来时分不清是哪一次的。
return state.status === 'saving' ? state : { pending: { value: event.value }, status: 'saving', error: null }
case 'succeeded':
return state.status === 'saving' ? { pending: null, status: 'saved', error: null } : state
case 'failed':
return state.status === 'saving' ? { pending: null, status: 'failed', error: event.error } : state
case 'expired':
return state.status === 'saved' ? idleSave : state
}
}
export function shownValue<T>(state: SettingSave<T>, saved: T): T {
return state.pending ? state.pending.value : saved
}
+67
View File
@@ -0,0 +1,67 @@
import type { InlineSaveEvent, InlineSaveState } from './inline-save'
import { useEffect, useRef, useState } from 'react'
import { inlineSave } from './inline-save'
const savedNoticeMs = 1500
// 文本的保存流程:失焦或回车时 submit,Esc 时 revert。onSave 失败时抛出 Error,它的 message 是给用户看的原因。
// submit 返回提交之后输入框里的值是否已经保存:没有改动也算已经保存;正在保存或者保存失败时返回 false。
export function useInlineSave(value: string, onSave: (value: string) => Promise<void>) {
const [state, setState] = useState<InlineSaveState>({ draft: value, saved: value, status: 'idle', error: null })
const [seenValue, setSeenValue] = useState(value)
const [savedNotice, setSavedNotice] = useState(0)
// state 要到下一次渲染才更新,回车之后紧跟着失焦时,由这个 ref 阻止重复保存。
const savingRef = useRef(false)
const apply = (event: InlineSaveEvent) => setState(current => inlineSave(current, event))
if (value !== seenValue) {
setSeenValue(value)
apply({ type: 'external', value })
}
useEffect(() => {
if (!savedNotice)
return
const timer = setTimeout(setSavedNotice, savedNoticeMs, 0)
return () => clearTimeout(timer)
}, [savedNotice])
const submit = async (): Promise<boolean> => {
if (savingRef.current)
return false
if (inlineSave(state, { type: 'submit' }).status !== 'saving') {
apply({ type: 'submit' })
return true
}
savingRef.current = true
const submitted = state.draft
const savedBefore = state.saved
apply({ type: 'submit' })
try {
await onSave(submitted)
setState((current) => {
const done = inlineSave(current, { type: 'succeeded', value: submitted })
// 保存期间外部传入了新值时以它为准:服务端可能改写了提交的值,比如去掉首尾空白。
return current.saved === savedBefore ? done : inlineSave(done, { type: 'external', value: current.saved })
})
setSavedNotice(count => count + 1)
return true
}
catch (error) {
apply({ type: 'failed', error: error instanceof Error && error.message ? error.message : '没有保存,再试一次。' })
return false
}
finally {
savingRef.current = false
}
}
return {
state,
// “已保存”在保存成功后显示 1.5 秒;又改了输入框就不再显示。
savedNotice: state.status === 'saved' && savedNotice > 0 && state.draft === state.saved,
edit: (draft: string) => apply({ type: 'edit', value: draft }),
submit,
revert: () => apply({ type: 'revert' }),
}
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useReducer, useRef, useState } from 'react'
import { idleSave, settingSave, shownValue } from './setting-save'
// “已保存”显示 1.5 秒,再用 0.4 秒淡出,和 ui.css 里 .save-status-saved 的动画一致。
export const savedNoticeMs = 1900
// 改完立即保存。save 同步时写的是这台设备上的 store,抛出的是浏览器的存储错误;异步时写服务端,失败时抛出 Error,message 是给用户看的原因。
export function useSettingSave<T>(saved: T, save: (value: T) => void | Promise<void>) {
const [state, dispatch] = useReducer(settingSave<T>, idleSave)
const [savedVersion, setSavedVersion] = useState(0)
// state 要到下一次渲染才更新,连续点击时由这个 ref 阻止同时保存两次。
const savingRef = useRef(false)
useEffect(() => {
if (state.status !== 'saved')
return
const timer = setTimeout(dispatch, savedNoticeMs, { type: 'expired' })
return () => clearTimeout(timer)
}, [state])
const change = (value: T) => {
if (savingRef.current || value === shownValue(state, saved))
return
dispatch({ type: 'start', value })
let result: void | Promise<void>
try {
result = save(value)
}
catch {
dispatch({ type: 'failed', error: '没有保存,浏览器不允许在这台设备上保存设置。' })
return
}
const succeeded = () => {
dispatch({ type: 'succeeded' })
setSavedVersion(version => version + 1)
}
if (!(result instanceof Promise)) {
succeeded()
return
}
savingRef.current = true
result.then(
succeeded,
(error: unknown) => dispatch({ type: 'failed', error: error instanceof Error && error.message ? error.message : '没有保存,再试一次。' }),
).finally(() => {
savingRef.current = false
})
}
return { value: shownValue(state, saved), state, savedVersion, change }
}
@@ -106,14 +106,14 @@ export function ActionMenu({ actions, menuLabel, align = 'end', className, label
>
{children}
</button>
<div ref={menuRef} id={menuId} className="playlist-menu" popover="auto" role="menu" aria-label={menuLabel} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
<div ref={menuRef} id={menuId} className="action-menu" popover="auto" role="menu" aria-label={menuLabel} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
{actions.map(({ icon: Icon, label, danger, run }) => (
<button
key={label}
type="button"
role="menuitem"
tabIndex={-1}
className={danger ? 'playlist-menu-item playlist-menu-danger' : 'playlist-menu-item'}
className={danger ? 'action-menu-item action-menu-danger' : 'action-menu-item'}
onClick={() => {
menuRef.current!.hidePopover()
run()
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more