diff --git a/app/web/src/components/pages/SettingsAdminBody.tsx b/app/web/src/components/pages/SettingsAdminBody.tsx new file mode 100644 index 0000000..71de85a --- /dev/null +++ b/app/web/src/components/pages/SettingsAdminBody.tsx @@ -0,0 +1,103 @@ +import type { Locale, MessageKey } from "@app/i18n"; +import { t } from "@app/i18n"; +import { Button, Card, Input, toast } from "@app/ui"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { createTRPCReactClient, trpc } from "~/lib/trpc"; + +function Inner(props: { locale: Locale }) { + const { locale } = props; + const me = trpc.auth.me.useQuery(); + const isAdmin = me.data?.user?.role === "admin"; + const openNet = trpc.settings.getOpenNetwork.useQuery(undefined, { enabled: isAdmin }); + const saveOpenNet = trpc.settings.saveOpenNetwork.useMutation(); + + const [appId, setAppId] = useState(""); + const [appSecret, setAppSecret] = useState(""); + const [filled, setFilled] = useState(false); + + useEffect(() => { + if (!openNet.data || filled) return; + setAppId(openNet.data.appId); + setAppSecret(openNet.data.appSecret); + setFilled(true); + }, [openNet.data, filled]); + + if (me.isLoading) { + return

{t(locale, "watch.loading")}

; + } + if (!isAdmin) { + return null; + } + + const label = (key: MessageKey) => t(locale, key); + + return ( + +
+

{label("settings.adminSection")}

+ + {label("settings.adminOnly")} + +
+

{label("settings.openNetworkHint")}

+ +
+

{label("settings.openAppId")}

+ setAppId(e.target.value)} + placeholder="AppId" + autoComplete="off" + /> +
+
+

{label("settings.openAppSecret")}

+ setAppSecret(e.target.value)} + placeholder="AppSecret" + autoComplete="off" + /> +

{label("settings.secretPlainHint")}

+
+ +
+ + {label("settings.openSource")}: {openNet.data?.source ?? "—"} + + {openNet.data?.envConfigured && {label("settings.envConfigured")}} +
+ +
+ +
+
+ ); +} + +export function SettingsAdminBody(props: { locale: Locale }) { + const [qc] = useState(() => new QueryClient()); + const client = createTRPCReactClient(); + return ( + + + + + + ); +} diff --git a/app/web/src/pages/[locale]/settings.astro b/app/web/src/pages/[locale]/settings.astro index bf4b041..7a3597f 100644 --- a/app/web/src/pages/[locale]/settings.astro +++ b/app/web/src/pages/[locale]/settings.astro @@ -1,6 +1,15 @@ --- -import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n"; -import AuthView from "~/views/auth.astro"; +import { + COOKIE_LANG, + isLocale, + localePath, + resolvePreferredLocale, + t, + type Locale, +} from "@app/i18n"; +import AppLayout from "~/layouts/AppLayout.astro"; +import AuthBody from "~/components/pages/AuthBody"; +import { SettingsAdminBody } from "~/components/pages/SettingsAdminBody"; const raw = Astro.params["locale"]; const normalized = raw === "zh-cn" ? "zh-CN" : raw; @@ -14,6 +23,16 @@ if (!normalized || !isLocale(normalized)) { } const locale: Locale = normalized; +const pageTitle = `${t(locale, "auth.settings.title")} · ${t(locale, "meta.brand")}`; --- - + +
+ + +
+
diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts index 6fe7c07..1404e7f 100644 --- a/e2e/i18n.spec.ts +++ b/e2e/i18n.spec.ts @@ -52,4 +52,6 @@ test("header 语言下拉显示当前语言并可切换", async ({ page }) => { await expect(item).toBeVisible(); await item.click(); await expect(page).toHaveURL(/\/en$/); + const enTrigger = page.getByRole("button", { name: "Language" }); + await expect(enTrigger).toContainText("English"); }); diff --git a/packages/danmaku/src/client.ts b/packages/danmaku/src/client.ts index 0f1b47b..5bc6b63 100644 --- a/packages/danmaku/src/client.ts +++ b/packages/danmaku/src/client.ts @@ -19,7 +19,18 @@ export function readOpenCredentials(env = process.env): OpenCredentials | null { } export function hasOpenCredentials(): boolean { - return readOpenCredentials() !== null; + return effectiveCredentials() !== null; +} + +/** 运行时覆盖凭证(管理员在设置里配置);null 表示回退环境变量 */ +let runtimeCredentials: OpenCredentials | null = null; + +export function setOpenCredentials(creds: OpenCredentials | null): void { + runtimeCredentials = creds; +} + +export function effectiveCredentials(): OpenCredentials | null { + return runtimeCredentials ?? readOpenCredentials(); } export function openHeaders(path: string, creds: OpenCredentials | null): Record { @@ -38,7 +49,7 @@ export async function openFetchJson( path: string, init?: { method?: "POST"; body?: string } | undefined, ): Promise<{ ok: true; json: unknown } | { ok: false; status: number | null; error: string }> { - const creds = readOpenCredentials(); + const creds = effectiveCredentials(); try { const res = await fetch(`${openApiBase()}${path}`, { method: init?.method ?? "GET", diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts index 91ffd31..bf69249 100644 --- a/packages/danmaku/src/index.ts +++ b/packages/danmaku/src/index.ts @@ -15,6 +15,8 @@ export { openMatch, openSearchEpisodes, readOpenCredentials, + setOpenCredentials, + effectiveCredentials, } from "./client.js"; export type { OpenAnimeBrief, diff --git a/packages/dao/src/app-settings.ts b/packages/dao/src/app-settings.ts new file mode 100644 index 0000000..ded3258 --- /dev/null +++ b/packages/dao/src/app-settings.ts @@ -0,0 +1,41 @@ +import { eq } from "drizzle-orm"; +import { appSettings, type AppSettingRow } from "@app/models"; +import { db } from "@app/db"; + +export const appSettingsDao = { + async get(key: string): Promise { + const [row] = await db.select().from(appSettings).where(eq(appSettings.key, key)).limit(1); + return row ?? null; + }, + async upsert(data: { + key: string; + value: string; + updatedBy?: string | null | undefined; + }): Promise { + const existing = await appSettingsDao.get(data.key); + if (existing) { + const [row] = await db + .update(appSettings) + .set({ + value: data.value, + updatedBy: data.updatedBy ?? null, + updatedAt: new Date(), + }) + .where(eq(appSettings.id, existing.id)) + .returning(); + if (!row) throw new Error("Failed to update app setting"); + return row; + } + const rows = await db + .insert(appSettings) + .values({ + key: data.key, + value: data.value, + updatedBy: data.updatedBy ?? null, + }) + .returning(); + const row = rows[0]; + if (!row) throw new Error("Failed to insert app setting"); + return row; + }, +}; diff --git a/packages/dao/src/index.ts b/packages/dao/src/index.ts index a537aaf..8a240f3 100644 --- a/packages/dao/src/index.ts +++ b/packages/dao/src/index.ts @@ -13,3 +13,5 @@ export { playbackProgressDao } from "./playback-progress.js"; export { danmakuCacheDao } from "./danmaku-cache.js"; export { danmakuPrefsDao } from "./danmaku-prefs.js"; export { danmakuDocsDao } from "./danmaku-docs.js"; +export { appSettingsDao } from "./app-settings.js"; +export type { AppSettingRow } from "@app/models"; diff --git a/packages/dao/src/users.ts b/packages/dao/src/users.ts index bb19fcb..31ae477 100644 --- a/packages/dao/src/users.ts +++ b/packages/dao/src/users.ts @@ -21,6 +21,11 @@ export const userDao = { return { rows, total: Number(countRow?.["count"] ?? 0) }; }, + async countAll(): Promise { + const [countRow] = await db.select({ count: sql`count(*)` }).from(users); + return Number(countRow?.["count"] ?? 0); + }, + async getById(id: string): Promise { const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1); return row ?? null; @@ -49,6 +54,7 @@ export const userDao = { id?: string; username: string; email?: string | null; + role?: string; }, ): Promise { const payload = { diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index 89c698e..0fe52c1 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -319,4 +319,16 @@ export const en: Record = { "error.internal.title": "Something broke", "error.internal.desc": "The server hit an unexpected error. Retry later and report the path if it persists.", + + "settings.adminSection": "Open danmaku network", + "settings.adminOnly": "Admin only", + "settings.openNetworkHint": "Configure DanDanPlay open network AppId / AppSecret for online danmaku matching and fetch. Admin only.", + "settings.openAppId": "AppId", + "settings.openAppSecret": "AppSecret", + "settings.secretPlainHint": "Secret is stored in plaintext and visible to admins only.", + "settings.openSource": "Current source", + "settings.envConfigured": "Env configured", + "settings.openSaved": "Saved and applied", + "settings.openSaveFailed": "Save failed", + "settings.save": "Save", }; diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index c0ebf41..7e0340e 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -310,6 +310,17 @@ export const zhCN = { "error.internal.label": "系统异常", "error.internal.title": "服务出错了", "error.internal.desc": "服务器处理请求时发生意外。请稍后重试;若持续出现请反馈路径与时间。", + "settings.adminSection": "开放弹幕网络", + "settings.adminOnly": "仅管理员", + "settings.openNetworkHint": "配置弹弹play 开放网络 AppId / AppSecret,用于在线弹幕匹配与拉取。仅管理员可见可改。", + "settings.openAppId": "AppId", + "settings.openAppSecret": "AppSecret", + "settings.secretPlainHint": "Secret 以明文保存,仅管理员可查看与修改。", + "settings.openSource": "当前来源", + "settings.envConfigured": "环境变量已配置", + "settings.openSaved": "已保存并生效", + "settings.openSaveFailed": "保存失败", + "settings.save": "保存", } as const; export type MessageKey = keyof typeof zhCN; diff --git a/packages/models/src/app-settings.ts b/packages/models/src/app-settings.ts new file mode 100644 index 0000000..a2fd894 --- /dev/null +++ b/packages/models/src/app-settings.ts @@ -0,0 +1,35 @@ +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { users } from "./users.js"; + +/** + * 应用级配置(跨用户共享)。敏感值存密文。 + * 唯一键 `key`;由管理员经 API 写入,服务端启动/读取时解密。 + */ +export const appSettings = sqliteTable( + "app_settings", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + key: text("key").notNull(), + /** 明文值;敏感项应写 encryptSecret() 密文 */ + value: text("value").notNull().default(""), + /** 由谁改的(审计) */ + updatedBy: text("updated_by").references(() => users.id, { onDelete: "set null" }), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (t) => [uniqueIndex("app_settings_key_uq").on(t.key)], +); + +export type AppSettingRow = typeof appSettings.$inferSelect; +export type NewAppSettingRow = typeof appSettings.$inferInsert; + +/** 开放弹幕网络配置键 */ +export const APP_SETTING_OPEN_DANMAKU_APP_ID = "open_danmaku_app_id"; +export const APP_SETTING_OPEN_DANMAKU_APP_SECRET = "open_danmaku_app_secret"; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 91cbf33..98117b2 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -17,3 +17,5 @@ export { danmakuPrefs } from "./danmaku-prefs.js"; export type { DanmakuPrefsRow, NewDanmakuPrefsRow } from "./danmaku-prefs.js"; export { danmakuDocs } from "./danmaku-docs.js"; export type { DanmakuDocRow, NewDanmakuDocRow } from "./danmaku-docs.js"; +export { appSettings, APP_SETTING_OPEN_DANMAKU_APP_ID, APP_SETTING_OPEN_DANMAKU_APP_SECRET } from "./app-settings.js"; +export type { AppSettingRow, NewAppSettingRow } from "./app-settings.js"; diff --git a/packages/models/src/users.ts b/packages/models/src/users.ts index 03b049e..0260eff 100644 --- a/packages/models/src/users.ts +++ b/packages/models/src/users.ts @@ -17,6 +17,8 @@ export const users = sqliteTable("users", { email: text("email").unique(), /** scrypt 输出,格式 `salt:hash`(hex)。default "" 便于给已有行加列。 */ passwordHash: text("password_hash").notNull().default(""), + /** 角色:admin=管理员(可改系统配置)| user=普通用户 */ + role: text("role").notNull().default("user"), createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), }); diff --git a/packages/trpc/src/router/index.ts b/packages/trpc/src/router/index.ts index c9197fb..dea4646 100644 --- a/packages/trpc/src/router/index.ts +++ b/packages/trpc/src/router/index.ts @@ -9,6 +9,7 @@ import { userService } from "../services/user.service.js"; import type { TrpcContext } from "../context.js"; import { authRouter } from "./auth.router.js"; import { mediaRouter } from "./media.router.js"; +import { settingsRouter } from "./settings.router.js"; // ─── User router ──────────────────────────────────────────────────────── export const userRouter = t.router({ @@ -36,6 +37,7 @@ export const appRouter = t.router({ user: userRouter, auth: authRouter, media: mediaRouter, + settings: settingsRouter, }); // ─── Type re-exports ──────────────────────────────────────────────────── diff --git a/packages/trpc/src/router/settings.router.ts b/packages/trpc/src/router/settings.router.ts new file mode 100644 index 0000000..b9990b2 --- /dev/null +++ b/packages/trpc/src/router/settings.router.ts @@ -0,0 +1,14 @@ +import { settingsSchemas } from "@app/types"; +import { adminProcedure } from "../services/procedure.js"; +import { settingsService } from "../services/settings.service.js"; +import { t } from "../context.js"; + +export const settingsRouter = t.router({ + /** 开放弹幕网络(仅管理员;secret 明文) */ + getOpenNetwork: adminProcedure + .input(settingsSchemas.getOpenNetwork) + .query(() => settingsService.getOpenNetworkSettings()), + saveOpenNetwork: adminProcedure + .input(settingsSchemas.saveOpenNetwork) + .mutation(({ ctx, input }) => settingsService.saveOpenNetworkSettings(ctx.userId, input)), +}); diff --git a/packages/trpc/src/services/auth.service.ts b/packages/trpc/src/services/auth.service.ts index c4efbd8..eb2d269 100644 --- a/packages/trpc/src/services/auth.service.ts +++ b/packages/trpc/src/services/auth.service.ts @@ -54,7 +54,8 @@ export interface ResetPasswordInput { } function toAuthUser(row: UserRow): AuthUser { - return { id: row.id, username: row.username, name: row.name, email: row.email }; + const role = row.role === "admin" ? "admin" : "user"; + return { id: row.id, username: row.username, name: row.name, email: row.email, role }; } function newToken(): string { @@ -140,11 +141,14 @@ export const authService = { const emailTaken = await userDao.getByEmail(email); if (emailTaken) throw new AuthError("EMAIL_TAKEN", "该邮箱已被注册"); } + // 首个注册用户成为管理员,便于初始化开放弹幕等系统配置 + const userCount = await userDao.countAll(); const user = await userDao.create({ username, name: input.name?.trim() || username, email: email ?? null, passwordHash: hashPassword(input.password), + role: userCount === 0 ? "admin" : "user", }); await createSession(user.id, scope.setCookie); return { user: toAuthUser(user) }; diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts index 453d077..3d96616 100644 --- a/packages/trpc/src/services/danmaku.service.test.ts +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -11,6 +11,11 @@ import type { import { danmakuService } from "./danmaku.service.js"; import { createWebdav } from "./webdav-client.js"; +vi.mock("./settings.service.js", () => ({ + ensureOpenRuntime: vi.fn(async () => {}), + settingsService: {}, +})); + vi.mock("@app/dao", () => ({ danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() }, danmakuDocsDao: { get: vi.fn(), upsert: vi.fn(), remove: vi.fn() }, diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts index 324971e..4f695c6 100644 --- a/packages/trpc/src/services/danmaku.service.ts +++ b/packages/trpc/src/services/danmaku.service.ts @@ -20,6 +20,7 @@ import type { DanmakuSourceSearchOutput, } from "@app/types"; import { decryptSecret } from "./secret.js"; +import { ensureOpenRuntime } from "./settings.service.js"; import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -136,6 +137,7 @@ export const danmakuService = { }, async fetch(userId: string, mediaItemId: string): Promise { + await ensureOpenRuntime(); const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" }; @@ -219,6 +221,7 @@ export const danmakuService = { }, async matchCandidates(userId: string, mediaItemId: string): Promise { + await ensureOpenRuntime(); const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!item) { return { @@ -261,6 +264,7 @@ export const danmakuService = { mediaItemId: string, episodeId: number, ): Promise { + await ensureOpenRuntime(); const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!item) return { ok: false, source: "none", xml: "", message: "媒体不存在" }; await mediaItemDao.setDanmakuMatch(mediaItemId, userId, { @@ -305,6 +309,7 @@ export const danmakuService = { /** 手动指定弹幕源:搜作品 */ async searchSource(keyword: string): Promise { + await ensureOpenRuntime(); if (!hasOpenCredentials()) { return { ok: false, message: "未配置开放弹幕网络", animes: [] }; } @@ -326,6 +331,7 @@ export const danmakuService = { /** 手动指定弹幕源:列作品集数 */ async sourceEpisodes(animeId: number): Promise { + await ensureOpenRuntime(); if (!hasOpenCredentials()) { return { ok: false, diff --git a/packages/trpc/src/services/procedure.ts b/packages/trpc/src/services/procedure.ts index 8e54945..8514990 100644 --- a/packages/trpc/src/services/procedure.ts +++ b/packages/trpc/src/services/procedure.ts @@ -19,3 +19,12 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { }, }); }); + +/** 需要管理员角色;在 protectedProcedure 基础上校验 role。 */ +export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => { + const user = await authService.getSessionUser(ctx.sessionToken); + if (!user || user.role !== "admin") { + throw new TRPCError({ code: "FORBIDDEN", message: "需要管理员权限" }); + } + return next({ ctx: { ...ctx, role: user.role as "admin" } }); +}); diff --git a/packages/trpc/src/services/settings.service.ts b/packages/trpc/src/services/settings.service.ts new file mode 100644 index 0000000..891b4d4 --- /dev/null +++ b/packages/trpc/src/services/settings.service.ts @@ -0,0 +1,88 @@ +import { + APP_SETTING_OPEN_DANMAKU_APP_ID, + APP_SETTING_OPEN_DANMAKU_APP_SECRET, +} from "@app/models"; +import { appSettingsDao } from "@app/dao"; +import type { OpenNetworkSettingsOutput, SaveOpenNetworkSettingsInput } from "@app/types"; +import { setOpenCredentials, type OpenCredentials } from "@app/danmaku"; + +function envCredentials(): OpenCredentials | null { + const appId = process.env["OPEN_DANMAKU_APP_ID"]; + const appSecret = process.env["OPEN_DANMAKU_APP_SECRET"]; + if (!appId || !appSecret) return null; + return { appId, appSecret }; +} + +/** 读开放弹幕网络配置(管理员可见明文) */ +export async function getOpenNetworkSettings(): Promise { + const appIdRow = await appSettingsDao.get(APP_SETTING_OPEN_DANMAKU_APP_ID); + const secretRow = await appSettingsDao.get(APP_SETTING_OPEN_DANMAKU_APP_SECRET); + const appId = appIdRow?.value ?? ""; + const appSecret = secretRow?.value ?? ""; + const env = envCredentials(); + if (appId && appSecret) { + return { appId, appSecret, envConfigured: env != null, source: "db" }; + } + if (env) { + return { + appId: env.appId, + appSecret: env.appSecret, + envConfigured: true, + source: "env", + }; + } + return { appId, appSecret, envConfigured: false, source: "none" }; +} + +/** 保存开放弹幕网络配置(明文)并热更新运行时凭证 */ +export async function saveOpenNetworkSettings( + userId: string, + input: SaveOpenNetworkSettingsInput, +): Promise { + await appSettingsDao.upsert({ + key: APP_SETTING_OPEN_DANMAKU_APP_ID, + value: input.appId.trim(), + updatedBy: userId, + }); + await appSettingsDao.upsert({ + key: APP_SETTING_OPEN_DANMAKU_APP_SECRET, + value: input.appSecret, + updatedBy: userId, + }); + await applyRuntimeOpenCredentials(); + runtimeApplied = true; + return getOpenNetworkSettings(); +} + +/** 启动或保存后:把库内凭证注入 @app/danmaku(无则回退 env) */ +export async function applyRuntimeOpenCredentials(): Promise { + const appIdRow = await appSettingsDao.get(APP_SETTING_OPEN_DANMAKU_APP_ID); + const secretRow = await appSettingsDao.get(APP_SETTING_OPEN_DANMAKU_APP_SECRET); + const appId = appIdRow?.value.trim() ?? ""; + const appSecret = secretRow?.value ?? ""; + if (appId && appSecret) { + setOpenCredentials({ appId, appSecret }); + return; + } + setOpenCredentials(null); +} + +let runtimeApplied = false; + +/** 首次使用前注入库内凭证(幂等;失败不阻断业务) */ +export async function ensureOpenRuntime(): Promise { + if (runtimeApplied) return; + try { + await applyRuntimeOpenCredentials(); + } catch { + // 表未迁移或 DB 不可用时回退 env + } + runtimeApplied = true; +} + +export const settingsService = { + getOpenNetworkSettings, + saveOpenNetworkSettings, + applyRuntimeOpenCredentials, + ensureOpenRuntime, +}; diff --git a/packages/trpc/src/services/user.service.ts b/packages/trpc/src/services/user.service.ts index bde5a38..fd724e4 100644 --- a/packages/trpc/src/services/user.service.ts +++ b/packages/trpc/src/services/user.service.ts @@ -47,6 +47,7 @@ function toPublicUser(row: UserRow): PublicUser { username: row.username, name: row.name, email: row.email, + role: row.role, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -78,6 +79,7 @@ export const userService = { name: input.name?.trim() || username, email: input.email ?? null, passwordHash: hashPassword(input.password), + role: "user", }); return toPublicUser(row); }, diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts index 8af1c45..6a9da4a 100644 --- a/packages/types/src/schemas.ts +++ b/packages/types/src/schemas.ts @@ -102,6 +102,8 @@ export type User = { username: string; name: string; email: string | null; + /** admin | user */ + role: string; createdAt: Date | null; updatedAt: Date | null; }; @@ -112,6 +114,8 @@ export type AuthUser = { username: string; name: string; email: string | null; + /** admin | user — 前端据此做权限可见性 */ + role: "admin" | "user"; }; export type AuthSessionOutput = { @@ -133,3 +137,28 @@ export type UserListOutput = { limit: number; offset: number; }; + +// ─── Admin / system settings ──────────────────────────────────────────────── + +export const settingsSchemas = { + /** 开放弹幕网络(管理员,明文) */ + saveOpenNetwork: z.object({ + appId: z.string().max(200).default(""), + appSecret: z.string().max(500).default(""), + }), + getOpenNetwork: z.object({}).optional(), +}; + +/** 开放弹幕网络配置(管理员)。secret 明文仅供管理员读改。 */ +export type OpenNetworkSettingsOutput = { + appId: string; + appSecret: string; + /** env 是否提供了兜底凭证 */ + envConfigured: boolean; + source: "db" | "env" | "none"; +}; + +export type SaveOpenNetworkSettingsInput = { + appId: string; + appSecret: string; +}; diff --git a/playwright.config.ts b/playwright.config.ts index a2dcb1c..07be8a0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -20,7 +20,8 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, reporter: [["list"]], use: { - baseURL: "http://localhost:4321", + // 本机 4321 可能被 Docker(dandanplay-web)占用而复用到旧镜像,可用 E2E_BASE_URL 指向本地 dev + baseURL: process.env.E2E_BASE_URL ?? "http://localhost:4321", trace: "on-first-retry", locale: "en-US", acceptLanguage: "en-US", @@ -33,7 +34,7 @@ export default defineConfig({ ], webServer: { command: "yarn dev:web", - url: "http://localhost:4321", + url: process.env.E2E_BASE_URL ?? "http://localhost:4321", reuseExistingServer: !process.env.CI, timeout: 300_000, },