Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions WALLPAPERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Static site wallpapers

## Shop integration draft

Own profile now links to `/shop`, containing Products and My backpack. All three wallpapers cost 1,000 community coins each. The displayed balance comes from the existing `userInfo.Gold` browser cache and is explicitly labeled as cached, with unknown values shown as a dash. No wallet mutation, purchase request or ownership record is created. Purchasing is disabled; backpack availability is unknown until a service exists. Local preview is development-only, excluded from ownership, and production wallpaper selection fails closed pending an ownership service. Chinese and English shop strings are provided; other locales currently use English shop copy.

Required backend integration: authenticate the community account, obtain authorized community-wallet debit capability, atomically debit 1,000 coins and grant permanent ownership, prevent duplicate purchases with idempotency and unique ownership constraints, return authoritative balance/inventory, and persist equipment. An independent service alone cannot debit the community wallet without its authorized integration.

Local prototype on an independent branch. Open `/#/wallpapers`, or Settings > Site wallpaper. Three built-in CSS gradient/pattern backgrounds are bundled with the application. No uploads, remote image URLs, animation, payments or grants are supported.

The shared header also provides a wallpaper selector for comparing backgrounds directly on normal community pages. For live public content on this machine, the network-enabled development server is at `http://127.0.0.1:5177/#/`. Public home and discussion feeds were visually checked with real API data; authenticated notifications and friends still require user login. Automated home/discussion navigation uses fixture responses for repeatability.

The selected allowlisted ID is stored in this browser under the typed `siteWallpaper` preference. It persists across routes and reloads and synchronizes across same-origin tabs. Storage errors keep the previous selection. Unknown IDs fall back to the default background. This is a browser appearance preference shared by accounts using that browser, with no server or account ownership claims.

The application shell paints one fixed non-interactive background. Settings, editor outer backgrounds, the shared header and footer use a transparent override only when a wallpaper is selected. The login card explicitly uses the reactive wallpaper so it also works when mounted outside the app shell. Inputs, content cards, experiment covers and editor content retain readable surfaces. Restoring default removes the overrides and returns the login card to its default background.

## Test locally

Start `npm run dev -- --host 127.0.0.1 --port 5176 --strictPort` and open `http://127.0.0.1:5176/#/wallpapers`.

Run `npx playwright test --config scripts/tests/wallpapers.local.config.ts --workers=2`. The suite checks persistence, settings navigation, reset, tab synchronization, unknown IDs, keyboard selection and storage errors in Chromium and narrow-screen WebKit. These tests do not establish authenticated page coverage or real-device acceptance.

Before release: review authenticated content-heavy pages and editor layouts, check real mobile devices, and decide whether wallpaper selection should become an account setting. Account-owned/paid wallpapers require a server inventory and ownership checks before integration.
14 changes: 14 additions & 0 deletions scripts/tests/wallpapers.local.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig } from '@playwright/test'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

export default defineConfig({
testDir: '.',
testMatch: 'wallpapers.spec.ts',
outputDir: join(tmpdir(), 'plweb-wallpaper-tests'),
use: { baseURL: 'http://127.0.0.1:5176' },
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'webkit-mobile', use: { browserName: 'webkit', viewport: { width: 390, height: 844 } } },
],
})
140 changes: 140 additions & 0 deletions scripts/tests/wallpapers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { test, expect } from '@playwright/test'

test('商城展示缓存金币,关闭购买且试用不会扣款发货', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('userInfo', JSON.stringify({ value: { ID: '66d100000000000000000001', Gold: 2500, Nickname: '商城测试' } }))
})
const purchaseRequests: string[] = []
page.on('request', request => { if (request.method() === 'POST') purchaseRequests.push(request.url()) })
await page.goto('/#/shop')
await expect(page.locator('.shop__account')).toContainText('2,500')
await expect(page.locator('.shop__price')).toHaveCount(3)
for (const button of await page.locator('.shop__product button:disabled').all()) await expect(button).toBeDisabled()
await expect(page.locator('.shop__product button:disabled')).toHaveCount(3)
await page.locator('.shop__preview').first().click()
await expect(page.locator('.wallpaper-shell')).toHaveClass(/has-wallpaper/)
await page.locator('.shop__tabs button').nth(1).click()
await expect(page.locator('.shop__bag')).toBeVisible()
await page.locator('.shop__bag button').click()
await expect(page.locator('.wallpaper-shell')).not.toHaveClass(/has-wallpaper/)
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('userInfo')!).value.Gold)).toBe(2500)
expect(purchaseRequests).toEqual([])
})

test('正常首页和黑洞卡片中直接切换壁纸', async ({ page }) => {
const blocks = [
{
Header: '壁纸回归测试作品',
TargetLink: '{}',
Summaries: [
{
ID: '66d100000000000000000001',
Subject: '正常作品卡片',
Image: -1,
Category: 'Experiment',
Tags: [],
User: { Nickname: '测试作者' },
},
],
},
]
await page.route('**/api/Users/Authenticate', (route) =>
route.fulfill({ json: { Status: 200, Data: { Library: { Blocks: blocks } } } }),
)
await page.route('**/api/Contents/GetLibrary', (route) =>
route.fulfill({ json: { Status: 200, Data: { Blocks: blocks } } }),
)
await page.goto('/#/')
await page.locator('.cookie-notice button').click()
await expect(page.locator('#home .card')).toBeVisible()
await page.locator('.wallpaper-picker select').selectOption('tide')
await expect(page.locator('.wallpaper-shell')).toHaveClass(/has-wallpaper/)
await expect(page.locator('.header-container')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
await expect(page.locator('footer')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
await page.locator('#home .user').click()
await expect(page.locator('.login-surface')).toBeVisible()
await expect(page.locator('.login-surface')).not.toHaveCSS('background-image', 'none')
await page.locator('.login-surface input').first().fill('wallpaper-visual-check')
await expect(page.locator('.login-surface input').first()).toHaveValue('wallpaper-visual-check')
await page.locator('.container').filter({ has: page.locator('.login-surface') }).click({ position: { x: 5, y: 5 } })
await expect(page.locator('.login-surface')).toHaveCount(0)
await expect(page.locator('#home .block > .outer')).toHaveCSS(
'background-color',
'rgb(255, 255, 255)',
)
await page.locator('footer a[href="#/b"]').click()
await expect(page.locator('#blackhole .card')).toBeVisible()
await expect(page.locator('.wallpaper-picker select')).toHaveValue('tide')
await page.locator('.wallpaper-picker select').selectOption('grid')
await page.locator('footer a[href="#/"]').click()
await expect(page.locator('.wallpaper-picker select')).toHaveValue('grid')
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.locator('.wallpaper-picker select').selectOption('')
await expect(page.locator('.wallpaper-shell')).not.toHaveClass(/has-wallpaper/)
await expect(page.locator('.header-container')).toHaveCSS('background-color', 'rgb(255, 255, 255)')
await expect(page.locator('footer')).toHaveCSS('background-color', 'rgb(255, 255, 255)')
await page.locator('#home .user').click()
await expect(page.locator('.login-surface')).toHaveCSS('background-image', 'none')
})

test('壁纸可切换、刷新保留、跨页面生效并恢复默认', async ({ page }) => {
await page.goto('/#/wallpapers')
await expect(page.locator('input[type=file]')).toHaveCount(0)
await page.locator('[data-wallpaper=tide]').click()
await expect(page.locator('.wallpaper-shell')).toHaveClass(/has-wallpaper/)
await page.reload()
await expect(page.locator('[data-wallpaper=tide]')).toHaveAttribute('aria-pressed', 'true')
await page.locator('.wallpapers__intro a').click()
await expect(page.locator('.settings-wrapper')).toBeVisible()
await expect(page.locator('.settings-wrapper')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
await expect(page.locator('.wallpaper-shell')).toHaveClass(/has-wallpaper/)
await page.locator('a[href="#/wallpapers"]').click()
await page.locator('[data-wallpaper=dawn]').click()
await page.locator('[data-wallpaper=grid]').click()
await expect(page.locator('[data-wallpaper=grid]')).toHaveAttribute('aria-pressed', 'true')
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.locator('.wallpapers__card').first().click()
await expect(page.locator('.wallpaper-shell')).not.toHaveClass(/has-wallpaper/)
await page.reload()
await expect(page.locator('.wallpapers__card').first()).toHaveAttribute('aria-pressed', 'true')
})

test('同浏览器标签页同步应用和恢复默认', async ({ page, context }) => {
await page.goto('/#/wallpapers')
const other = await context.newPage()
await other.goto('/#/wallpapers')
await page.locator('[data-wallpaper=grid]').click()
await expect(other.locator('[data-wallpaper=grid]')).toHaveAttribute('aria-pressed', 'true')
await other.locator('.wallpapers__card').first().click()
await expect(page.locator('.wallpaper-shell')).not.toHaveClass(/has-wallpaper/)
await other.close()
})

test('未知壁纸安全回退且支持键盘选择', async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem(
'siteWallpaper',
JSON.stringify({ value: 'https://invalid.test/upload.jpg' }),
),
)
await page.goto('/#/wallpapers')
await expect(page.locator('.wallpapers__card').first()).toHaveAttribute('aria-pressed', 'true')
await page.locator('[data-wallpaper=dawn]').focus()
await page.keyboard.press('Enter')
await expect(page.locator('[data-wallpaper=dawn]')).toHaveAttribute('aria-pressed', 'true')
})

test('存储失败时保留原选择并显示提示', async ({ page }) => {
await page.addInitScript(() => {
const original = Storage.prototype.setItem
Storage.prototype.setItem = function (key, value) {
if (key === 'siteWallpaper') throw new DOMException('Storage full', 'QuotaExceededError')
original.call(this, key, value)
}
})
await page.goto('/#/wallpapers')
await page.locator('[data-wallpaper=tide]').click()
await expect(page.locator('.wallpapers__status')).not.toBeEmpty()
await expect(page.locator('.wallpapers__card').first()).toHaveAttribute('aria-pressed', 'true')
await expect(page.locator('.wallpaper-shell')).not.toHaveClass(/has-wallpaper/)
})
22 changes: 21 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<template>
<div id="app" @click="handleClick">
<div
id="app"
class="wallpaper-shell"
:class="{ 'has-wallpaper': wallpaper }"
:style="{ '--wallpaper-image': wallpaper?.background }"
@click="handleClick"
>
<CookieNotice />
<router-view v-slot="{ Component }">
<!-- keep alive源自于vue-router的缓存 -->
Expand All @@ -15,6 +21,8 @@
<script setup lang="ts">
import showUserCard from '@popup/userProfileDialog.ts'
import CookieNotice from './components/utils/CookieNotice.vue'
import { wallpaper, useWallpaperSync } from './services/wallpapers'
useWallpaperSync()
function handleClick(event: MouseEvent) {
const target = event.target as HTMLElement
if (target.classList.contains('RUser')) {
Expand All @@ -24,6 +32,18 @@ function handleClick(event: MouseEvent) {
</script>

<style>
.wallpaper-shell.has-wallpaper {
isolation: isolate;
--page-background: transparent;
}
.wallpaper-shell.has-wallpaper::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: var(--wallpaper-image);
}
html,
body {
height: 100dvh !important;
Expand Down
3 changes: 2 additions & 1 deletion src/components/popup/loginModel.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="container" @click="close">
<div class="card" @click.stop>
<n-card>
<n-card class="login-surface" :style="{ backgroundImage: wallpaper?.background }">
<n-tabs
class="card-tabs"
default-value="signin"
Expand Down Expand Up @@ -93,6 +93,7 @@

<script setup lang="ts">
import { ref } from 'vue'
import { wallpaper } from '../../services/wallpapers'
import { login } from '@api/getData.ts'
import sm from '@storage/index'
import { getPath } from '@services/utils'
Expand Down
2 changes: 1 addition & 1 deletion src/components/utils/Footer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ footer {
bottom: 0;
left: 0;
right: 0;
background-color: white;
background-color: var(--page-background, white);
border-top: 1px solid #e5e7eb;
box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.1);
z-index: 50;
Expand Down
4 changes: 3 additions & 1 deletion src/components/utils/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<slot></slot>
<!-- @see https://icomoon.io/app/ -->
<div class="buttons">
<WallpaperPicker />
<div class="logout" @click="logout">
<svg width="25" height="25" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
Expand Down Expand Up @@ -33,6 +34,7 @@
<script setup lang="ts">
import router from '../../router/index'
import { ref } from 'vue'
import WallpaperPicker from './WallpaperPicker.vue'
let isFullScreen = ref(false)

function toggleFullScreen() {
Expand Down Expand Up @@ -69,7 +71,7 @@ function logout() {
top: 0;
padding: 0 0 0 20px;
font-size: small;
background-color: white;
background-color: var(--page-background, white);
width: 100%;
z-index: 100;
display: flex;
Expand Down
61 changes: 61 additions & 0 deletions src/components/utils/WallpaperPicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<template>
<div v-if="demo" class="wallpaper-picker">
<select :value="wallpaperId ?? ''" :aria-label="t('wallpapers.title')" @change="change">
<option value="">{{ t('wallpapers.default') }}</option>
<option v-for="item in WALLPAPERS" :key="item.id" :value="item.id">
{{ t(`wallpapers.${item.id}`) }}
</option>
</select>
<span v-if="failed" role="alert">{{ t('wallpapers.error') }}</span>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { WALLPAPERS, wallpaperId, setWallpaper, type WallpaperId } from '../../services/wallpapers'

const { t } = useI18n()
const demo = import.meta.env.DEV
const failed = ref(false)
function change(event: Event) {
const select = event.target as HTMLSelectElement
try {
setWallpaper((select.value || null) as WallpaperId | null)
failed.value = false
} catch {
select.value = wallpaperId.value ?? ''
failed.value = true
}
}
</script>

<style scoped>
.wallpaper-picker {
position: relative;
}
select {
max-width: clamp(5rem, 16vw, 10rem);
min-height: 2rem;
border: 1px solid #d7e3eb;
border-radius: 4px;
background: white;
color: #006b9e;
font: inherit;
}
select:focus-visible {
outline: 2px solid #0185c5;
outline-offset: 2px;
}
[role='alert'] {
position: absolute;
z-index: 200;
top: 100%;
right: 0;
width: 15rem;
padding: 0.75rem;
background: white;
color: #a21919;
box-shadow: 0 2px 8px #0002;
}
</style>
2 changes: 2 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export default {
shop: {"title":"Shop","balance":"Community coin balance","coins":"coins","cached":"Cached on this device; final charges require server verification.","login":"Sign in to read the cached community balance.","profile":"Profile","unavailable":"Purchase service unavailable. Charging, delivery and inventory lookup are disabled.","goods":"Products","bag":"My backpack","purchaseClosed":"Purchases unavailable","preview":"Local preview","bagUnavailable":"Inventory service is unavailable. Owned wallpapers cannot be confirmed. Previews are excluded.","previewNotice":"Development preview only: local appearance, no charge and no ownership grant.","endPreview":"End preview"},
wallpapers: {"title":"Website-Hintergrund","description":"Integrierte statische Hintergründe für die gesamte Website. Nur in diesem Browser gespeichert; keine Uploads.","settings":"Zurück zu Einstellungen","default":"Standardhintergrund","selected":"Ausgewählt","apply":"Überall anwenden","tide":"Gezeiten · Meeresblau","dawn":"Morgenrot · Pfirsichflieder","grid":"Notizbuch · Minzgrün","preview":"Lesevorschau","readability":"Der Hintergrund liegt hinter der Seite. Inhaltskarten behalten ihren klaren Hintergrund. Die Auswahl bleibt beim Seitenwechsel erhalten und lässt sich jederzeit zurücksetzen.","saved":"In diesem Browser gespeichert","error":"Speichern fehlgeschlagen. Bitte die Speicherberechtigungen des Browsers prüfen."},
login: {
emailOrPhone: 'E-Mail / Telefon',
password: 'Passwort 6~20 Zeichen',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export default {
shop: {"title":"Shop","balance":"Community coin balance","coins":"coins","cached":"Cached on this device; final charges require server verification.","login":"Sign in to read the cached community balance.","profile":"Profile","unavailable":"Purchase service unavailable. Charging, delivery and inventory lookup are disabled.","goods":"Products","bag":"My backpack","purchaseClosed":"Purchases unavailable","preview":"Local preview","bagUnavailable":"Inventory service is unavailable. Owned wallpapers cannot be confirmed. Previews are excluded.","previewNotice":"Development preview only: local appearance, no charge and no ownership grant.","endPreview":"End preview"},
wallpapers: {"title":"Site wallpaper","description":"Built-in static wallpapers for the whole community. Saved in this browser only; no uploads.","settings":"Back to settings","default":"Default background","selected":"Selected","apply":"Apply site-wide","tide":"Tide · Sea blue","dawn":"Dawn · Peach lilac","grid":"Notebook · Mint","preview":"Reading preview","readability":"Wallpaper sits behind the page while content cards retain clear backgrounds. Your selection persists across pages and can be reset anytime.","saved":"Saved in this browser","error":"Could not save. Check browser storage permissions."},
login: {
emailOrPhone: 'Email / Phone',
password: 'Password 6~20 chars',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export default {
shop: {"title":"Shop","balance":"Community coin balance","coins":"coins","cached":"Cached on this device; final charges require server verification.","login":"Sign in to read the cached community balance.","profile":"Profile","unavailable":"Purchase service unavailable. Charging, delivery and inventory lookup are disabled.","goods":"Products","bag":"My backpack","purchaseClosed":"Purchases unavailable","preview":"Local preview","bagUnavailable":"Inventory service is unavailable. Owned wallpapers cannot be confirmed. Previews are excluded.","previewNotice":"Development preview only: local appearance, no charge and no ownership grant.","endPreview":"End preview"},
wallpapers: {"title":"Fond du site","description":"Des fonds statiques intégrés pour tout le site. Enregistrés dans ce navigateur uniquement, sans importation.","settings":"Retour aux paramètres","default":"Fond par défaut","selected":"Sélectionné","apply":"Appliquer partout","tide":"Marée · Bleu marin","dawn":"Aube · Pêche lilas","grid":"Carnet · Menthe","preview":"Aperçu de lecture","readability":"Le fond reste derrière la page et les cartes conservent un fond lisible. Le choix est conservé entre les pages et peut être réinitialisé à tout moment.","saved":"Enregistré dans ce navigateur","error":"Échec de l’enregistrement. Vérifiez les autorisations de stockage du navigateur."},
login: {
emailOrPhone: 'E-mail / Téléphone',
password: 'Mot de passe 6~20 caractères',
Expand Down
Loading
Loading