diff --git a/backend/internal/app/appearance.go b/backend/internal/app/appearance.go index 664904216..03f0415f4 100644 --- a/backend/internal/app/appearance.go +++ b/backend/internal/app/appearance.go @@ -48,6 +48,7 @@ const ( defaultAppearanceVideoURL = "https://boss-shjd.biliapi.net/updream/aniforge/video/video_bbcb00bd-650d-4249-9346-5cd21fd2484c_m1hc-u0-1pu13x-3v1s.mp4" defaultAppearancePosterURL = "https://i0.hdslb.com/bfs/aitool/aniforge/image/02933f26-5f1b-49ff-a811-b7f95ee5e5b8_m1hc-u0-sau.jpg" defaultAppearanceHeroTitle = "让一个故事,\n从文字走向银幕。" + defaultRedeemPurchaseURL = "https://wzyp.cn/shop/JPDK4SRD" ) type AppearanceSetting struct { @@ -72,6 +73,7 @@ type AppearanceSetting struct { FooterCopyright string `json:"footerCopyright"` ICPFilingEnabled bool `json:"icpFilingEnabled"` ICPFilingNumber string `json:"icpFilingNumber"` + RedeemPurchaseURL string `json:"redeemPurchaseUrl"` } type PublicAppearanceSetting struct { @@ -96,6 +98,7 @@ type PublicAppearanceSetting struct { FooterCopyright string `json:"footerCopyright"` ICPFilingEnabled bool `json:"icpFilingEnabled"` ICPFilingNumber string `json:"icpFilingNumber"` + RedeemPurchaseURL string `json:"redeemPurchaseUrl"` LogoConfigured bool `json:"logoConfigured"` DarkLogoConfigured bool `json:"darkLogoConfigured"` AuthVideoConfigured bool `json:"authVideoConfigured"` @@ -125,6 +128,7 @@ func defaultAppearanceSetting() AppearanceSetting { LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID, SkinThemes: defaultAppearanceSkinThemes(), + RedeemPurchaseURL: defaultRedeemPurchaseURL, } } @@ -205,6 +209,7 @@ func (s *Service) UpdateAppearance(actor *model.User, value AppearanceSetting) ( value.SEOKeywords = normalizeAppearanceSingleLine(value.SEOKeywords) value.FooterCopyright = normalizeAppearanceSingleLine(value.FooterCopyright) value.ICPFilingNumber = normalizeAppearanceSingleLine(value.ICPFilingNumber) + value.RedeemPurchaseURL = strings.TrimSpace(value.RedeemPurchaseURL) if err := validateAppearanceSetting(value); err != nil { return nil, err } @@ -443,6 +448,7 @@ func (s *Service) readAppearance() (*model.SystemSetting, AppearanceSetting, err value.SEOKeywords = normalizeAppearanceSingleLine(value.SEOKeywords) value.FooterCopyright = normalizeAppearanceSingleLine(value.FooterCopyright) value.ICPFilingNumber = normalizeAppearanceSingleLine(value.ICPFilingNumber) + value.RedeemPurchaseURL = strings.TrimSpace(value.RedeemPurchaseURL) return setting, value, nil } @@ -521,6 +527,12 @@ func validateAppearanceSetting(value AppearanceSetting) error { if value.ICPFilingEnabled && value.ICPFilingNumber == "" { return BadAuthRequest("显示备案号前请先填写备案号") } + if value.RedeemPurchaseURL != "" { + parsed, err := url.Parse(value.RedeemPurchaseURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return BadAuthRequest("兑换码购买链接必须是有效的 HTTPS 地址") + } + } for _, resourceID := range []string{value.LogoResourceID, value.DarkLogoResourceID, value.AuthVideoResourceID, value.AuthVideoPosterResourceID} { if len(resourceID) > 80 { return BadAuthRequest("外观资源 ID 无效") @@ -704,6 +716,7 @@ func publicAppearanceSetting(setting *model.SystemSetting, value AppearanceSetti FooterCopyright: effectiveAppearanceCopyright(value), ICPFilingEnabled: value.ICPFilingEnabled && value.ICPFilingNumber != "", ICPFilingNumber: value.ICPFilingNumber, + RedeemPurchaseURL: safeAppearancePurchaseURL(value.RedeemPurchaseURL), Configured: setting != nil, Revision: revision, } @@ -743,6 +756,18 @@ func publicAppearanceSetting(setting *model.SystemSetting, value AppearanceSetti return result } +func safeAppearancePurchaseURL(value string) string { + candidate := strings.TrimSpace(value) + if candidate == "" { + return "" + } + parsed, err := url.Parse(candidate) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "" + } + return parsed.String() +} + func effectiveAppearanceSEOTitle(value AppearanceSetting) string { if value.SEOTitle != "" { return value.SEOTitle diff --git a/web/src/components/layout/workspace-wallet-modal.tsx b/web/src/components/layout/workspace-wallet-modal.tsx index de6e85d1f..0e9e4dd46 100644 --- a/web/src/components/layout/workspace-wallet-modal.tsx +++ b/web/src/components/layout/workspace-wallet-modal.tsx @@ -10,6 +10,7 @@ import { closePaymentOrder, createPaymentOrder, getPaymentOrder, listPaymentProv import { getWallet, redeemCredits, type CreditLedgerEntry, type WalletSummary } from "@/services/api/wallet"; import { cn } from "@/lib/utils"; import { openWorkspaceWallet, WORKSPACE_WALLET_OPEN_EVENT, type WorkspaceWalletOpenDetail } from "@/lib/workspace-wallet"; +import { useAppearanceStore } from "@/stores/use-appearance-store"; import { useUserStore } from "@/stores/use-user-store"; type WalletModalTab = "topup" | "redeem" | "history"; @@ -64,6 +65,7 @@ export function WorkspaceWalletHost() { export function WorkspaceWalletModal({ open, onClose, pendingPaymentOrderId, paymentInvalid }: { open: boolean; onClose: () => void; pendingPaymentOrderId?: string; paymentInvalid?: boolean }) { const { message } = App.useApp(); + const redeemPurchaseUrl = useAppearanceStore((state) => state.appearance.redeemPurchaseUrl); const [tab, setTab] = useState("topup"); const [wallet, setWallet] = useState(null); const [walletLoading, setWalletLoading] = useState(false); @@ -378,7 +380,14 @@ export function WorkspaceWalletModal({ open, onClose, pendingPaymentOrderId, pay

兑换码

-

输入兑换码,将积分存入当前账户。

+

+ 购买后请复制卡密,返回此处兑换。 + {redeemPurchaseUrl ? ( + + 获取兑换码 + + ) : null} +

diff --git a/web/src/pages/admin/settings/appearance-settings-page.tsx b/web/src/pages/admin/settings/appearance-settings-page.tsx index 7f8e93124..a81bbc615 100644 --- a/web/src/pages/admin/settings/appearance-settings-page.tsx +++ b/web/src/pages/admin/settings/appearance-settings-page.tsx @@ -45,6 +45,7 @@ export default function AppearanceSettingsPage() { const [footerCopyright, setFooterCopyright] = useState(""); const [icpFilingEnabled, setIcpFilingEnabled] = useState(false); const [icpFilingNumber, setIcpFilingNumber] = useState(""); + const [redeemPurchaseUrl, setRedeemPurchaseUrl] = useState(""); const [files, setFiles] = useState(EMPTY_FILES); const [resets, setResets] = useState(EMPTY_RESETS); const [loading, setLoading] = useState(true); @@ -79,6 +80,7 @@ export default function AppearanceSettingsPage() { normalizeSingleLine(footerCopyright) !== setting?.footerCopyright || icpFilingEnabled !== setting?.icpFilingEnabled || normalizeSingleLine(icpFilingNumber) !== setting?.icpFilingNumber || + normalizeSingleLine(redeemPurchaseUrl) !== setting?.redeemPurchaseUrl || Object.values(files).some(Boolean) || Object.values(resets).some(Boolean)); const blocker = useBlocker(dirty && !saving && !restoring); @@ -103,6 +105,7 @@ export default function AppearanceSettingsPage() { setFooterCopyright(value.footerCopyright); setIcpFilingEnabled(value.icpFilingEnabled); setIcpFilingNumber(value.icpFilingNumber); + setRedeemPurchaseUrl(value.redeemPurchaseUrl); setFiles(EMPTY_FILES); setResets(EMPTY_RESETS); setLoadError(""); @@ -214,6 +217,7 @@ export default function AppearanceSettingsPage() { setFooterCopyright(setting.footerCopyright); setIcpFilingEnabled(setting.icpFilingEnabled); setIcpFilingNumber(setting.icpFilingNumber); + setRedeemPurchaseUrl(setting.redeemPurchaseUrl); setFiles(EMPTY_FILES); setResets(EMPTY_RESETS); Object.values(inputRefs).forEach((ref) => { @@ -289,6 +293,7 @@ export default function AppearanceSettingsPage() { const nextSeoKeywords = normalizeSingleLine(seoKeywords); const nextFooterCopyright = normalizeSingleLine(footerCopyright); const nextIcpFilingNumber = normalizeSingleLine(icpFilingNumber); + const nextRedeemPurchaseUrl = normalizeSingleLine(redeemPurchaseUrl); if (!nextBrandName || Array.from(nextBrandName).length > 40) { setActiveTab("brand"); message.error("品牌名称必须为 1 到 40 个字符"); @@ -327,6 +332,16 @@ export default function AppearanceSettingsPage() { message.error("显示备案号前请先填写备案号"); return; } + if (nextRedeemPurchaseUrl) { + try { + const parsed = new URL(nextRedeemPurchaseUrl); + if (parsed.protocol !== "https:" || !parsed.host) throw new Error("invalid"); + } catch { + setActiveTab("footer"); + message.error("兑换码购买链接必须是有效的 HTTPS 地址"); + return; + } + } const skinError = validateSkinDrafts(skinThemes, skinId); if (skinError) { setActiveTab("skins"); @@ -370,6 +385,7 @@ export default function AppearanceSettingsPage() { footerCopyright: nextFooterCopyright, icpFilingEnabled, icpFilingNumber: nextIcpFilingNumber, + redeemPurchaseUrl: nextRedeemPurchaseUrl, }); applySetting(updated); commitPublicAppearance(updated.public); @@ -732,6 +748,9 @@ export default function AppearanceSettingsPage() { setIcpFilingNumber(event.target.value)} /> + + setRedeemPurchaseUrl(event.target.value)} /> +
diff --git a/web/src/services/api/appearance.ts b/web/src/services/api/appearance.ts index 0dd809349..c47e5f4a7 100644 --- a/web/src/services/api/appearance.ts +++ b/web/src/services/api/appearance.ts @@ -26,6 +26,7 @@ export type PublicAppearance = { footerCopyright: string; icpFilingEnabled: boolean; icpFilingNumber: string; + redeemPurchaseUrl: string; logoConfigured: boolean; darkLogoConfigured: boolean; authVideoConfigured: boolean; @@ -57,6 +58,7 @@ export type AdminAppearance = { footerCopyright: string; icpFilingEnabled: boolean; icpFilingNumber: string; + redeemPurchaseUrl: string; public: PublicAppearance; configured: boolean; updatedBy?: string; @@ -107,6 +109,7 @@ export async function updateAdminAppearance( | "footerCopyright" | "icpFilingEnabled" | "icpFilingNumber" + | "redeemPurchaseUrl" >, ) { const result = await http.patch<{ setting: AdminAppearance }>("/admin/settings/appearance", input); diff --git a/web/src/stores/use-appearance-store.ts b/web/src/stores/use-appearance-store.ts index dd522c9d4..8411610a6 100644 --- a/web/src/stores/use-appearance-store.ts +++ b/web/src/stores/use-appearance-store.ts @@ -27,6 +27,7 @@ export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = { footerCopyright: `© ${new Date().getFullYear()} 影策. All rights reserved.`, icpFilingEnabled: false, icpFilingNumber: "", + redeemPurchaseUrl: "", logoConfigured: false, darkLogoConfigured: false, authVideoConfigured: false, @@ -61,6 +62,7 @@ export function normalizePublicAppearance(value?: Partial | nu const seoKeywords = normalizeAppearanceCopy(value?.seoKeywords, "", true); const footerCopyright = normalizeAppearanceCopy(value?.footerCopyright, `© ${new Date().getFullYear()} ${resolvedBrandName}. All rights reserved.`); const icpFilingNumber = normalizeAppearanceCopy(value?.icpFilingNumber, "", true); + const redeemPurchaseUrl = typeof value?.redeemPurchaseUrl === "string" ? safeAppearanceURL(value.redeemPurchaseUrl, "") : ""; return { ...DEFAULT_PUBLIC_APPEARANCE, ...value, @@ -85,6 +87,7 @@ export function normalizePublicAppearance(value?: Partial | nu footerCopyright, icpFilingEnabled: Boolean(value?.icpFilingEnabled && icpFilingNumber), icpFilingNumber, + redeemPurchaseUrl, logoConfigured: Boolean(value?.logoConfigured), darkLogoConfigured: Boolean(value?.darkLogoConfigured), authVideoConfigured: customVideo, diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index dc94bbf74..e42cfae27 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -17034,6 +17034,31 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0 font-size: 11px; } +.workspace-wallet-redeem-link { + display: inline-flex; + align-items: center; + margin-left: 8px; + border-radius: 6px; + background: var(--user-accent-soft); + padding: 3px 9px; + color: var(--user-accent) !important; + font-size: 13px; + font-weight: 650; + line-height: 1.5; + text-decoration: none; + white-space: nowrap; +} + +.workspace-wallet-redeem-link:hover { + background: color-mix(in srgb, var(--user-accent) 16%, transparent); + text-decoration: underline; +} + +.workspace-wallet-redeem-link:focus-visible { + outline: 2px solid var(--user-accent); + outline-offset: 2px; +} + .workspace-wallet-section-heading > svg { width: 20px; height: 20px;