feat: enhance CollectionsWorkspace with improved UI and functionality

- Refactor collection renaming logic to maintain state integrity.
- Update UI elements for better accessibility and user experience in CollectionsWorkspace.
- Implement responsive design for collection details and asset layout.
- Add comprehensive tests for CollectionsWorkspace and LibraryWorkspace components.
- Introduce sync_env script for managing .env files with safety and structure.
- Create tests for sync_env functionality to ensure correct behavior and error handling.
This commit is contained in:
infstellar
2026-08-19 15:28:46 +08:00
parent 85b74253ae
commit bd37ae07df
10 changed files with 723 additions and 38 deletions
+4 -1
View File
@@ -85,10 +85,13 @@ cd MemeMeow
2. 创建 Compose 配置
```bash
cp .env.example .env
uv run python -m scripts.sync_env
# 编辑 .env,填写需要的模型凭据和可选视觉权重配置
```
重复运行同步命令时,`.env.example` 继续提供字段顺序、分组注释和默认值;已有
`.env` 的同名字段值会被保留,模板中没有的本地字段会追加到文件末尾。
3. 启动 Compose 全栈
```bash
./start.sh start
+31 -3
View File
@@ -91,13 +91,17 @@ test('完整重试使用 scope 端点,取消不请求且 320px 弹层不越界
/** 使用真实浏览器核对默认选择、指定阶段多选和桌面/移动弹层布局。 */
test('重试选中默认可选择并按指定阶段提交', async ({ page }) => {
const stageRequests = []
const fullRetryRequests = []
const consoleErrors = []
page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()) })
page.on('pageerror', (error) => { consoleErrors.push(error.message) })
await page.route('**/api/images?**', (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{ meme_id: '55555555-5555-4555-8555-555555555555', filename: 'pending.png', size: 1024, extension: '.png', media_url: '/media/pending', metadata: { status: 'pending' }, embedding_status: 'pending', visual_embedding_status: 'pending' },
{ meme_id: '66666666-6666-4666-8666-666666666666', filename: 'ready.png', size: 2048, extension: '.png', media_url: '/media/ready', metadata: { status: 'ready' }, embedding_status: 'ready', visual_embedding_status: 'ready' },
{ meme_id: '55555555-5555-4555-8555-555555555555', filename: 'pending.png', size: 1024, extension: '.png', media_url: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', metadata: { status: 'pending' }, embedding_status: 'pending', visual_embedding_status: 'pending' },
{ meme_id: '66666666-6666-4666-8666-666666666666', filename: 'ready.png', size: 2048, extension: '.png', media_url: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', metadata: { status: 'ready' }, embedding_status: 'ready', visual_embedding_status: 'ready' },
],
total: 2,
page: 1,
@@ -112,6 +116,14 @@ test('重试选中默认可选择并按指定阶段提交', async ({ page }) =>
body: JSON.stringify({ target_count: 2, submitted_count: 2, failed_count: 0, results: [] }),
})
})
await page.route('**/api/images/context/batch', async (route) => {
fullRetryRequests.push(route.request())
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({ results: [{ meme_id: '55555555-5555-4555-8555-555555555555', task_id: 'job-1' }] }),
})
})
await page.goto('/')
await page.setViewportSize({ width: 1440, height: 1000 })
@@ -121,9 +133,24 @@ test('重试选中默认可选择并按指定阶段提交', async ({ page }) =>
await expect(page.getByRole('button', { name: /^重试选中/ })).toBeDisabled()
await page.locator('.image-check input').first().check()
await page.getByRole('button', { name: /^重试选中/ }).click()
const retryButton = page.getByRole('button', { name: /^重试选中/ })
expect(stageRequests).toHaveLength(0)
expect(fullRetryRequests).toHaveLength(0)
await retryButton.click()
const dialog = page.getByRole('dialog', { name: '重试选中' })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('已选 1 张图片,选择本次要重新处理的范围。')
await expect(dialog.getByRole('button', { name: '完整重试' })).toBeEnabled()
expect(stageRequests).toHaveLength(0)
expect(fullRetryRequests).toHaveLength(0)
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
await expect(retryButton).toBeFocused()
expect(stageRequests).toHaveLength(0)
expect(fullRetryRequests).toHaveLength(0)
await retryButton.click()
await expect(dialog).toBeVisible()
await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/retry-selected-desktop.png', fullPage: true })
await dialog.getByRole('radio', { name: '指定部分' }).check()
@@ -138,6 +165,7 @@ test('重试选中默认可选择并按指定阶段提交', async ({ page }) =>
items: [{ meme_id: '55555555-5555-4555-8555-555555555555' }],
stages: ['agent', 'text_embedding'],
})
expect(consoleErrors).toEqual([])
})
test('首页可加载并切换核心工作区', async ({ page }) => {
+80
View File
@@ -0,0 +1,80 @@
/** 使用真实 Chromium 验证合集列表、详情和响应式资产布局。 */
import { expect, test } from '@playwright/test'
import { readFileSync } from 'node:fs'
const image = readFileSync(new URL('../../legacy/streamlit-v1/screenshots/streamlit_vvquest.png', import.meta.url))
const collection = {
collection_id: 'collection-1',
name: '本周会议里的反应合集',
member_count: 2,
cover_media_url: '/media/member-1',
}
const members = [
{
meme_id: 'member-1',
filename: 'meeting-reaction-with-a-long-descriptive-file-name.png',
extension: '.png',
media_url: '/media/member-1',
},
{
meme_id: 'member-2',
filename: 'waiting-for-the-decision.jpg',
extension: '.jpg',
media_url: '/media/member-2',
},
]
test('合集列表和详情在桌面、移动端保持可扫描且图片可见', async ({ page }) => {
const consoleErrors = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
await page.route('**/api/config', (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ embedding_model: 'test-model', embedding_cache_ready: true }),
}))
await page.route('**/api/collections**', (route) => {
const path = new URL(route.request().url()).pathname
const body = path.endsWith('/collection-1') ? { ...collection, members } : { items: [collection], total: 1, page: 1, page_size: 50 }
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
})
await page.route('**/api/images/metadata**', (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ filename: members[0].filename }),
}))
await page.route('**/media/**', (route) => route.fulfill({ status: 200, contentType: 'image/png', body: image }))
await page.goto('/')
await page.setViewportSize({ width: 1440, height: 1000 })
await page.getByRole('button', { name: '合集' }).click()
await expect(page.getByRole('heading', { name: '合集', exact: true })).toBeVisible()
await expect(page.locator('.collection-cover img')).toHaveCount(1)
await expect.poll(() => page.locator('.collection-cover img').evaluate((image) => image.naturalWidth)).toBeGreaterThan(0)
await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/collections-list-desktop.png', fullPage: true })
await page.setViewportSize({ width: 390, height: 844 })
await expect(page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).resolves.toBe(true)
await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/collections-list-mobile.png', fullPage: true })
await page.getByRole('button', { name: `打开合集 ${collection.name}` }).click()
await expect(page.getByRole('heading', { name: collection.name })).toBeVisible()
await expect(page.getByRole('button', { name: `从合集移除 ${members[0].filename}` })).toBeVisible()
await expect(page.locator('.collection-asset-media img')).toHaveCount(2)
await expect.poll(() => page.locator('.collection-asset-media img').first().evaluate((image) => image.naturalWidth)).toBeGreaterThan(0)
await page.getByRole('button', { name: `查看 ${members[0].filename} 图片与元数据` }).click()
await expect(page.getByRole('button', { name: '关闭图片预览' })).toBeVisible()
await page.getByRole('button', { name: '关闭图片预览' }).click()
await page.setViewportSize({ width: 320, height: 844 })
await expect(page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).resolves.toBe(true)
await page.setViewportSize({ width: 390, height: 844 })
await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/collections-detail-mobile.png', fullPage: true })
await page.setViewportSize({ width: 1440, height: 1000 })
await expect(page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).resolves.toBe(true)
await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/collections-detail-desktop.png', fullPage: true })
await page.getByRole('button', { name: '返回合集列表' }).focus()
await expect(page.getByRole('button', { name: '返回合集列表' })).toBeFocused()
expect(consoleErrors).toEqual([])
})
@@ -0,0 +1,110 @@
/** 合集列表、详情和成员维护行为测试,确保视觉整理不改变业务契约。 */
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { collections, createCollection, collection, renameCollection, deleteCollection, removeCollectionMember, imageMetadata } = vi.hoisted(() => ({
collections: vi.fn(),
createCollection: vi.fn(),
collection: vi.fn(),
renameCollection: vi.fn(),
deleteCollection: vi.fn(),
removeCollectionMember: vi.fn(),
imageMetadata: vi.fn(),
}))
vi.mock('../api', () => ({
api: { collections, createCollection, collection, renameCollection, deleteCollection, removeCollectionMember, imageMetadata },
}))
import CollectionsWorkspace from './CollectionsWorkspace.vue'
const member = {
meme_id: 'meme-1',
filename: 'a-very-long-image-name-that-stays-readable.png',
extension: '.png',
media_url: '/media/meme-1',
}
const summary = {
collection_id: 'collection-1',
name: '会议反应',
member_count: 1,
cover_media_url: '/media/meme-1',
}
describe('CollectionsWorkspace', () => {
beforeEach(() => {
collections.mockReset().mockResolvedValue({ items: [summary] })
createCollection.mockReset().mockResolvedValue({ collection_id: 'collection-2', name: '新合集' })
collection.mockReset().mockResolvedValue({ ...summary, members: [member] })
renameCollection.mockReset().mockResolvedValue({ ...summary, name: '改名后的合集' })
deleteCollection.mockReset().mockResolvedValue({})
removeCollectionMember.mockReset().mockResolvedValue({})
imageMetadata.mockReset().mockResolvedValue({})
vi.stubGlobal('prompt', vi.fn())
vi.stubGlobal('confirm', vi.fn(() => true))
})
it('通过内联表单创建合集并刷新列表', async () => {
const created = { collection_id: 'collection-2', name: '新合集', member_count: 0 }
createCollection.mockResolvedValueOnce(created)
collections.mockResolvedValueOnce({ items: [summary] }).mockResolvedValueOnce({ items: [summary, created] })
const wrapper = mount(CollectionsWorkspace)
await flushPromises()
await wrapper.get('#collection-name').setValue('新合集')
await wrapper.get('.collection-create-form').trigger('submit')
await flushPromises()
expect(createCollection).toHaveBeenCalledWith({ name: '新合集' })
expect(wrapper.findAll('.collection-row')).toHaveLength(2)
expect(wrapper.text()).toContain('合集已创建')
})
it('进入详情后保留重命名、返回和删除操作', async () => {
const wrapper = mount(CollectionsWorkspace)
await flushPromises()
await wrapper.get('[aria-label="打开合集 会议反应"]').trigger('click')
await flushPromises()
expect(collection).toHaveBeenCalledWith('collection-1')
expect(wrapper.get('h1').text()).toBe('会议反应')
expect(wrapper.get('.collection-gallery')).toBeTruthy()
window.prompt = vi.fn(() => '改名后的合集')
collections.mockResolvedValueOnce({ items: [summary] })
await wrapper.get('.collection-detail-actions .quiet').trigger('click')
await flushPromises()
expect(renameCollection).toHaveBeenCalledWith('collection-1', { name: '改名后的合集' })
expect(wrapper.get('h1').text()).toBe('改名后的合集')
collections.mockResolvedValueOnce({ items: [summary] })
await wrapper.get('.collection-back').trigger('click')
await flushPromises()
expect(wrapper.get('h1').text()).toBe('合集')
await wrapper.get('[aria-label="打开合集 会议反应"]').trigger('click')
await flushPromises()
window.confirm = vi.fn(() => true)
collections.mockResolvedValueOnce({ items: [] })
await wrapper.get('.collection-detail-actions .collection-danger').trigger('click')
await flushPromises()
expect(deleteCollection).toHaveBeenCalledWith('collection-1')
expect(wrapper.get('h1').text()).toBe('合集')
})
it('在资产卡片内移除成员并刷新详情', async () => {
const wrapper = mount(CollectionsWorkspace)
await flushPromises()
await wrapper.get('[aria-label="打开合集 会议反应"]').trigger('click')
await flushPromises()
collection.mockResolvedValueOnce({ ...summary, member_count: 0, members: [] })
await wrapper.get('[aria-label="从合集移除 a-very-long-image-name-that-stays-readable.png"]').trigger('click')
await flushPromises()
expect(removeCollectionMember).toHaveBeenCalledWith('collection-1', 'meme-1')
expect(wrapper.get('.collection-empty').text()).toContain('这个合集还没有图片')
expect(wrapper.text()).toContain('图片已从合集移除')
})
})
@@ -55,7 +55,9 @@ async function renameCollection(item: CollectionSummary): Promise<void> {
try {
await api.renameCollection(item.collection_id, { name })
notice.value = '合集已重命名'
if (selectedCollection.value?.collection_id === item.collection_id) selectedCollection.value.name = name
if (selectedCollection.value?.collection_id === item.collection_id) {
selectedCollection.value = { ...selectedCollection.value, name }
}
await loadCollections()
} catch (reason) {
emit('error', errorMessage(reason))
@@ -128,45 +130,70 @@ onMounted(loadCollections)
<template>
<section class="workspace" :aria-busy="busy">
<div class="section-head">
<div>
<div class="section-head" :class="{ 'collection-section-head': selectedCollection }">
<div class="collection-heading">
<button v-if="selectedCollection" class="collection-back" type="button" @click="backToCollections">
返回合集列表
</button>
<h1>{{ selectedCollection ? selectedCollection.name : '合集' }}</h1>
<p>{{ selectedCollection ? '管理合集成员。删除合集不会删除图片。' : '用合集组织图片库中的资产。' }}</p>
<p v-if="selectedCollection">
{{ members.length }} 张图片<span aria-hidden="true"> · </span>删除合集不会删除图片。
</p>
<p v-else>用合集组织图片库中的资产。</p>
</div>
<div v-if="selectedCollection" class="collection-detail-actions">
<div v-if="selectedCollection" class="collection-detail-actions" role="group" aria-label="合集管理操作">
<button class="quiet" type="button" :disabled="busy" @click="renameCollection(selectedCollection)">重命名</button>
<button class="quiet" type="button" :disabled="busy" @click="deleteCollection(selectedCollection)">删除</button>
<button class="quiet" type="button" @click="backToCollections">返回合集</button>
<button class="quiet collection-danger" type="button" :disabled="busy" @click="deleteCollection(selectedCollection)">删除合集</button>
</div>
</div>
<template v-if="selectedCollection">
<div class="library-list" role="list">
<article v-for="item in members" :key="item.meme_id" class="library-row" role="listitem">
<button class="library-preview-trigger" type="button" :aria-label="`查看 ${item.filename} 图片与元数据`" @click="openImagePreview(item, $event)">
<div class="collection-gallery" role="list" aria-label="合集图片">
<article v-for="item in members" :key="item.meme_id" class="collection-asset" role="listitem">
<button class="collection-asset-media" type="button" :aria-label="`查看 ${item.filename} 图片与元数据`" @click="openImagePreview(item, $event)">
<img :src="item.media_url" :alt="`预览 ${item.filename}`" loading="lazy" />
</button>
<div class="file-meta"><strong :title="item.filename">{{ item.filename }}</strong><small>{{ item.extension }}</small></div>
<button class="quiet" type="button" :disabled="busy" @click="removeMember(item)">移除</button>
<div class="collection-asset-meta">
<strong :title="item.filename">{{ item.filename }}</strong>
<small>{{ item.extension || '图片' }}</small>
<button class="quiet collection-remove" type="button" :disabled="busy" :aria-label="`从合集移除 ${item.filename}`" @click="removeMember(item)">
移除
</button>
</div>
</article>
<div v-if="!members.length" class="empty-state compact"><h2>这个合集还没有图片</h2><p>在图片库选择图片后加入合集。</p></div>
<div v-if="!members.length" class="empty-state compact collection-empty">
<h2>这个合集还没有图片</h2>
<p>在图片库选择图片后加入合集。</p>
</div>
</div>
<p v-if="notice" class="inline-notice" role="status">{{ notice }}</p>
</template>
<template v-else>
<div class="toolbar collection-toolbar">
<input v-model="collectionName" aria-label="合集名称" placeholder="新合集名称" @keyup.enter="createCollection" />
<button class="primary" type="button" :disabled="busy || !collectionName.trim()" @click="createCollection">创建合集</button>
</div>
<form class="collection-create-form" aria-label="创建合集" @submit.prevent="createCollection">
<label class="sr-only" for="collection-name">新合集名称</label>
<input id="collection-name" v-model="collectionName" placeholder="新合集名称" autocomplete="off" />
<button class="primary" type="submit" :disabled="busy || !collectionName.trim()">
{{ busy ? '创建中...' : '创建合集' }}
</button>
</form>
<p v-if="notice" class="inline-notice" role="status">{{ notice }}</p>
<div class="library-list" role="list">
<article v-for="item in collections" :key="item.collection_id" class="library-row collection-row" role="listitem">
<button class="collection-open" type="button" :disabled="busy" @click="openCollection(item)">
<img v-if="item.cover_media_url" :src="item.cover_media_url" :alt="item.name" />
<span class="file-meta"><strong>{{ item.name }}</strong><small>{{ item.member_count }} 张图片</small></span>
<div v-if="busy && !collections.length" class="empty-state compact collection-loading" role="status">
<h2>正在加载合集</h2>
</div>
<div v-else class="collection-list" role="list" aria-label="合集列表">
<article v-for="item in collections" :key="item.collection_id" class="collection-row" role="listitem">
<button class="collection-open" type="button" :disabled="busy" :aria-label="`打开合集 ${item.name}`" @click="openCollection(item)">
<span class="collection-cover" aria-hidden="true">
<img v-if="item.cover_media_url" :src="item.cover_media_url" alt="" loading="lazy" />
<span v-else>无图</span>
</span>
<span class="collection-summary">
<strong :title="item.name">{{ item.name }}</strong>
<span class="collection-count">{{ item.member_count ?? 0 }} 张图片</span>
</span>
</button>
<div class="collection-actions">
<div class="collection-actions" role="group" aria-label="合集操作">
<button class="quiet" type="button" :disabled="busy" @click="renameCollection(item)">重命名</button>
<button class="quiet" type="button" :disabled="busy" @click="deleteCollection(item)">删除</button>
<button class="quiet collection-danger" type="button" :disabled="busy" @click="deleteCollection(item)">删除合集</button>
</div>
</article>
<div v-if="!collections.length && !busy" class="empty-state compact"><h2>还没有合集</h2><p>创建合集后可从图片库添加成员。</p></div>
@@ -15,6 +15,7 @@ vi.mock('../api', () => ({
api: { images, imageMetadata, contextBatch, retryImageStagesBatch, unreadyProcessing, submitImageStage },
}))
import { api } from '../api'
import LibraryWorkspace from './LibraryWorkspace.vue'
const image = {
@@ -123,6 +124,50 @@ describe('LibraryWorkspace', () => {
expect(unreadyProcessing.mock.calls[0][0]).not.toHaveProperty('cursor')
})
it('即使完整重试能力尚未注入,点击主按钮也先显示图片处理选项且不请求', async () => {
const submitUnreadyProcessing = api.unreadyProcessing
delete api.unreadyProcessing
try {
const wrapper = mount(LibraryWorkspace, {
props: { config: null, cacheTask: null, cacheBusy: false, refreshToken: 0 },
})
await flushPromises()
await fullRetryButton(wrapper).trigger('click')
expect(wrapper.get('[role="dialog"][aria-labelledby="processing-options-title"]').text()).toContain('图片处理选项')
expect(unreadyProcessing).not.toHaveBeenCalled()
await wrapper.get('[aria-label="取消图片处理"]').trigger('click')
expect(unreadyProcessing).not.toHaveBeenCalled()
wrapper.unmount()
} finally {
api.unreadyProcessing = submitUnreadyProcessing
}
})
it('提交失败时保留已选处理选项,允许用户再次确认', async () => {
unreadyProcessing.mockRejectedValueOnce(new Error('network_failed'))
const wrapper = mount(LibraryWorkspace, {
props: { config: { reverse_image_available: true }, cacheTask: null, cacheBusy: false, refreshToken: 0 },
})
await flushPromises()
await fullRetryButton(wrapper).trigger('click')
await wrapper.get('.processing-options-dialog input[value="auto"]').setValue(true)
await wrapper.get('.processing-options-dialog input[type="checkbox"]').setValue(true)
await wrapper.get('.processing-options-dialog form').trigger('submit')
await flushPromises()
expect(wrapper.get('.processing-options-dialog input[value="auto"]').element.checked).toBe(true)
expect(wrapper.get('.processing-options-dialog input[type="checkbox"]').element.checked).toBe(true)
expect(unreadyProcessing).toHaveBeenCalledTimes(1)
await wrapper.get('.processing-options-dialog form').trigger('submit')
await flushPromises()
expect(unreadyProcessing).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('按提交、复用、冲突和失败分类展示 scope 级摘要及逐图结果', async () => {
unreadyProcessing.mockResolvedValue({
target_count: 4,
+7 -5
View File
@@ -258,10 +258,6 @@ async function confirmRetrySelected(payload: { mode: SelectedImageRetryMode; sta
/** 打开 scope 级完整重试的共享选项对话框。 */
function openUnreadyOptions(event: MouseEvent): void {
if (retryBusy.value) return
if (typeof api.unreadyProcessing !== 'function') {
emit('error', '完整重试服务不可用')
return
}
if (!preserveRetryOptions.value) retryOptions.value = { reverse_image_policy: 'forbid', auto_name: false }
retryDetails.value = []
processingOptionsTrigger.value = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
@@ -284,9 +280,15 @@ async function confirmUnreadyOptions(options: ImageProcessingOptions): Promise<v
retryNotice.value = ''
retryOptions.value = options
preserveRetryOptions.value = true
const submitUnreadyProcessing = api.unreadyProcessing
if (typeof submitUnreadyProcessing !== 'function') {
// API 能力尚未注入时也先保留选项弹层,避免点击主按钮变成无反馈操作。
emit('error', '完整重试服务不可用')
return
}
retryBusy.value = true
try {
const response = await api.unreadyProcessing(options) as UnreadyProcessingResponse
const response = await submitUnreadyProcessing(options) as UnreadyProcessingResponse
retryDetails.value = response.results || []
retryNotice.value = `完整重试:目标 ${response.target_count ?? 0},提交 ${response.submitted_count ?? 0},复用 ${response.reused_count ?? 0},冲突 ${response.conflict_count ?? 0},失败 ${response.failed_count ?? 0}`
processingOptionsOpen.value = false
+42 -5
View File
@@ -298,12 +298,40 @@ button, input, select, textarea { border-radius: var(--radius-control); }
.retry-stage-hint.error { color: var(--error); }
.collection-form { display: grid; gap: 16px; padding: 20px 24px 24px; }
.collection-inline-create { display: grid; gap: 12px; border-top: 1px solid var(--line); padding-top: 16px; }
.collection-toolbar { align-items: stretch; }
/* 合集沿用工作台阅读轴,列表和详情只增加必要的资产布局。 */
.collection-heading { min-width: 0; }
.collection-back { display: block; min-height: 44px; margin: -4px 0 8px; border: 0; padding: 0; color: var(--accent-deep); background: transparent; font-size: 15px; font-weight: 700; line-height: 1.3; text-align: left; }
.collection-back:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
.collection-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
.collection-row { width: 100%; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; }
.collection-row img { display: block; width: 64px; height: 64px; object-fit: contain; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); }
.collection-open { display: flex; flex: 1; align-items: center; gap: 14px; border: 0; background: transparent; text-align: left; }
.collection-actions { display: flex; gap: 8px; margin-left: auto; }
.collection-danger { border-color: var(--line-error); color: var(--error); }
.collection-danger:hover:not(:disabled) { border-color: var(--error); background: var(--surface-error); color: #8d3037; }
.collection-create-form { display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: stretch; gap: 10px; max-width: 720px; margin-bottom: 26px; }
.collection-create-form input { width: 100%; min-width: 0; height: 50px; border: 1px solid var(--line-strong); padding: 0 14px; color: var(--ink-soft); background: var(--surface); font-size: 16px; }
.collection-create-form input::placeholder { color: var(--muted-soft); opacity: 1; }
.collection-create-form .primary { min-height: 50px; }
.collection-list { border-top: 1px solid var(--line); }
.collection-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: 18px; min-height: 88px; border-bottom: 1px solid var(--line); padding: 11px 0; }
.collection-open { display: grid; grid-template-columns: 64px minmax(0, 1fr); align-items: center; gap: 14px; min-width: 0; border: 0; padding: 0; color: var(--ink-soft); background: transparent; text-align: left; }
.collection-open:hover:not(:disabled) .collection-summary strong { color: var(--accent-deep); }
.collection-open:disabled { opacity: .72; }
.collection-cover { display: grid; width: 64px; height: 64px; place-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 5px; color: var(--muted-soft); background: var(--surface-muted); font-size: 13px; }
.collection-cover img { display: block; width: 100%; height: 100%; object-fit: contain; background: var(--surface); }
.collection-summary { display: grid; gap: 5px; min-width: 0; }
.collection-summary strong { display: block; min-width: 0; overflow: hidden; color: var(--ink-soft); font-size: 16px; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; text-overflow: ellipsis; }
.collection-count { display: block; color: var(--muted-soft); font-size: 14px; line-height: 1.35; white-space: nowrap; }
.collection-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
.collection-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 16px; }
.collection-asset { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-surface); background: var(--surface); }
.collection-asset-media { display: grid; width: 100%; aspect-ratio: 1; place-items: center; overflow: hidden; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 0; background: var(--workspace); }
.collection-asset-media:hover { background: var(--surface-muted); }
.collection-asset-media img { display: block; width: 100%; height: 100%; object-fit: contain; }
.collection-asset-meta { display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: 5px 10px; min-width: 0; padding: 10px 12px 11px; }
.collection-asset-meta strong { grid-column: 1 / -1; display: -webkit-box; min-width: 0; overflow: hidden; color: var(--ink-soft); font-size: 15px; font-weight: 700; line-height: 1.35; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.collection-asset-meta small { min-width: 0; color: var(--muted-soft); font-size: 14px; line-height: 1.3; }
.collection-remove { min-height: 40px; padding-inline: 11px; color: var(--error); }
.collection-remove:hover:not(:disabled) { border-color: var(--error); background: var(--surface-error); color: #8d3037; }
.collection-empty { grid-column: 1 / -1; }
.collection-loading { border-style: solid; background: var(--surface-muted); }
.metadata-panel { min-height: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--line); border-radius: 6px; }
.metadata-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 15px; border-bottom: 1px solid var(--line); }
.metadata-panel-head h3 { margin: 0; color: var(--ink-soft); font-size: 17px; }
@@ -398,7 +426,15 @@ button, input, select, textarea { border-radius: var(--radius-control); }
.task-toolbar select { width: 100%; min-width: 0; font-size: 16px; }
.task-drawer-backdrop { inset: 0; }
.task-drawer { width: 100%; padding: 24px 18px; }
.collection-section-head { display: grid; gap: 14px; }
.collection-detail-actions { justify-content: flex-start; width: 100%; }
.collection-create-form { grid-template-columns: minmax(0, 1fr); max-width: none; }
.collection-create-form .primary { width: 100%; }
.collection-row { grid-template-columns: minmax(0, 1fr); gap: 10px; padding: 12px 0; }
.collection-actions { justify-content: flex-start; }
.collection-gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.collection-asset-meta { padding-inline: 10px; }
.collection-asset-meta strong { font-size: 14px; }
.image-dialog-backdrop { padding: 10px; place-items: stretch; }
.image-dialog { max-height: calc(100vh - 20px); }
.image-dialog-head { padding: 16px; }
@@ -425,6 +461,7 @@ button, input, select, textarea { border-radius: var(--radius-control); }
.cache-pill { display: inline-flex; margin-top: 14px; }
.toolbar > input, .toolbar > select, .toolbar > button { flex-basis: 100%; width: 100%; }
.toolbar-group > button { flex-basis: 100%; width: 100%; }
.collection-gallery { grid-template-columns: minmax(0, 1fr); }
.processing-options-actions { grid-template-columns: 1fr; }
}
+231
View File
@@ -0,0 +1,231 @@
"""项目维护脚本:按 ``.env.example`` 的结构同步本地 ``.env``。"""
from __future__ import annotations
import argparse
import os
import re
import stat
import tempfile
from collections.abc import Sequence
from io import StringIO
from pathlib import Path
from dotenv.parser import Binding, parse_stream
def _parse_bindings(content: str, source: Path) -> list[Binding]:
"""解析 dotenv 文本并拒绝语法错误,供合并前的数据校验调用。"""
bindings = list(parse_stream(StringIO(content)))
errors = [str(binding.original.line) for binding in bindings if binding.error]
if errors:
lines = ", ".join(errors)
raise ValueError(f"{source} 存在无法解析的 dotenv 内容(起始行:{lines})")
return bindings
def _assignment_parts(binding: Binding) -> tuple[str, str, str] | None:
"""拆出赋值语句的前缀、原始值和后缀,避免改变引号及变量插值语义。"""
text = binding.original.string
quote: str | None = None
equals_at: int | None = None
for index, character in enumerate(text):
if character in {"'", '"'}:
escaped = index > 0 and text[index - 1] == "\\"
if not escaped and (quote is None or quote == character):
quote = None if quote == character else character
elif character == "=" and quote is None:
equals_at = index
break
elif character in {"\r", "\n"} and quote is None and text[:index].strip():
break
if equals_at is None:
return None
value_start = equals_at + 1
while value_start < len(text) and text[value_start].isspace() and text[value_start] not in {"\r", "\n"}:
value_start += 1
if value_start < len(text) and text[value_start] in {"'", '"'}:
delimiter = text[value_start]
value_end = value_start + 1
while value_end < len(text):
if text[value_end] == delimiter:
backslashes = 0
cursor = value_end - 1
while cursor >= value_start and text[cursor] == "\\":
backslashes += 1
cursor -= 1
if backslashes % 2 == 0:
value_end += 1
break
value_end += 1
else:
line_end = len(text)
for newline in ("\r", "\n"):
position = text.find(newline, value_start)
if position >= 0:
line_end = min(line_end, position)
unquoted = text[value_start:line_end]
comment = re.search(r"[^\S\r\n]+#", unquoted)
value_end = value_start + (comment.start() if comment else len(unquoted.rstrip()))
return text[:value_start], text[value_start:value_end], text[value_end:]
def _binding_head(binding: Binding) -> tuple[str, str]:
"""拆出模板字段名及其后缀,供无等号声明和普通赋值相互转换。"""
match = re.match(r"\s*(?:export[^\S\r\n]+)?(?:'[^']+'|[^=#\s]+)", binding.original.string)
if match is None:
raise ValueError(f"无法定位 dotenv 字段:{binding.key}")
return binding.original.string[: match.end()], binding.original.string[match.end() :]
def _without_leading_blank_lines(text: str) -> str:
"""移除解析器归入字段的前置空行,供模板外字段统一追加到文件尾部。"""
return re.sub(r"\A(?:[^\S\r\n]*(?:\r\n|\n|\r))+", "", text)
def _newline_for(content: str) -> str:
"""选择模板使用的换行符,供追加字段时保持文件风格一致。"""
return "\r\n" if "\r\n" in content else "\n"
def _read_text(path: Path) -> str:
"""读取 dotenv 原文且不转换换行符,供同步过程保留模板的文件风格。"""
with path.open("r", encoding="utf-8", newline="") as handle:
return handle.read()
def merge_env_text(example: str, current: str, *, example_path: Path, env_path: Path) -> str:
"""以模板结构合并 dotenv 文本,返回可直接写入目标文件的完整内容。"""
example_bindings = _parse_bindings(example, example_path)
current_bindings = _parse_bindings(current, env_path)
current_by_key: dict[str, Binding] = {}
for binding in current_bindings:
if binding.key is not None:
# 字典保留字段首次出现的位置,同时让最后一次定义提供最终生效值。
current_by_key[binding.key] = binding
template_keys = {binding.key for binding in example_bindings if binding.key is not None}
output: list[str] = []
for binding in example_bindings:
source = current_by_key.get(binding.key) if binding.key is not None else None
if source is None:
output.append(binding.original.string)
continue
template_parts = _assignment_parts(binding)
source_parts = _assignment_parts(source)
if template_parts is None and source_parts is None:
output.append(binding.original.string)
continue
if template_parts is None:
head, suffix = _binding_head(binding)
output.append(f"{head}={source_parts[1]}{suffix}")
continue
prefix, _, suffix = template_parts
if source_parts is None:
head, _ = _binding_head(binding)
output.append(f"{head}{suffix}")
else:
output.append(f"{prefix}{source_parts[1]}{suffix}")
extras = [
_without_leading_blank_lines(binding.original.string)
for key, binding in current_by_key.items()
if key not in template_keys
]
if not extras:
return "".join(output)
newline = _newline_for(example)
merged = "".join(output)
if merged and not merged.endswith(("\n", "\r")):
merged += newline
if merged and not merged.endswith(newline * 2):
merged += newline
for extra in extras:
merged += extra
if not merged.endswith(("\n", "\r")):
merged += newline
return merged
def _validate_target(target: Path) -> None:
"""确认写入目标不会跟随符号链接或覆盖非普通文件。"""
if target.is_symlink():
raise ValueError(f"拒绝写入符号链接:{target}")
if target.exists() and not target.is_file():
raise ValueError(f"写入目标不是普通文件:{target}")
for parent in (target.parent, *target.parent.parents):
if parent == parent.parent:
break
if parent.is_symlink():
raise ValueError(f"拒绝通过符号链接目录写入:{parent}")
def _atomic_write(target: Path, content: str) -> None:
"""以 ``0600`` 权限原子替换目标,避免密钥短暂暴露或留下半写文件。"""
_validate_target(target)
target.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, stat.S_IRUSR | stat.S_IWUSR)
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, target)
try:
directory_descriptor = os.open(target.parent, os.O_RDONLY)
try:
os.fsync(directory_descriptor)
finally:
os.close(directory_descriptor)
except OSError:
pass
except Exception:
temporary.unlink(missing_ok=True)
raise
def sync_env(example_path: str | Path = ".env.example", env_path: str | Path = ".env") -> Path:
"""读取模板和已有配置、完成合并并原子写回,供 CLI 或其他维护脚本调用。"""
example = Path(example_path).expanduser()
target = Path(env_path).expanduser()
if example.resolve() == target.resolve():
raise ValueError("模板与输出不能是同一个文件")
if not example.is_file():
raise FileNotFoundError(f"dotenv 模板不存在:{example}")
_validate_target(target)
example_text = _read_text(example)
current_text = _read_text(target) if target.exists() else ""
merged = merge_env_text(example_text, current_text, example_path=example, env_path=target)
_atomic_write(target, merged)
return target
def _parser() -> argparse.ArgumentParser:
"""构造命令行解析器,供入口函数和 CLI 测试复用。"""
parser = argparse.ArgumentParser(description="以 .env.example 为蓝本同步 .env")
parser.add_argument("--example", default=".env.example", help="dotenv 模板路径(默认:.env.example)")
parser.add_argument("--env", default=".env", help="dotenv 输出路径(默认:.env)")
return parser
def main(argv: Sequence[str] | None = None) -> int:
"""执行 dotenv 同步命令,并仅输出不含配置值的结果摘要。"""
parser = _parser()
arguments = parser.parse_args(argv)
try:
target = sync_env(arguments.example, arguments.env)
except (OSError, ValueError) as error:
parser.error(str(error))
print(f"已根据 {arguments.example} 生成 {target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+122
View File
@@ -0,0 +1,122 @@
"""dotenv 模板同步脚本的合并规则与安全写入测试。"""
from __future__ import annotations
import stat
from pathlib import Path
import pytest
from scripts.sync_env import merge_env_text, sync_env
def test_merge_uses_template_structure_and_preserves_raw_values(tmp_path: Path) -> None:
"""同名字段只移植原始值,模板的顺序、注释、默认值和换行保持不变。"""
example_path = tmp_path / ".env.example"
env_path = tmp_path / ".env"
example = "# group\nA=template # keep\nB=default\nMULTI=default\n"
current = "B='old # value' # discard\nA=${OTHER}\nMULTI=\"first\nsecond\"\n"
merged = merge_env_text(example, current, example_path=example_path, env_path=env_path)
assert merged == "# group\nA=${OTHER} # keep\nB='old # value'\nMULTI=\"first\nsecond\"\n"
def test_sync_creates_env_from_template_with_safe_permissions(tmp_path: Path) -> None:
"""目标不存在时直接复制模板语义,并把新文件权限限制为当前用户读写。"""
example = tmp_path / ".env.example"
target = tmp_path / ".env"
example.write_text("# template\nA=default\n", encoding="utf-8")
assert sync_env(example, target) == target
assert target.read_text(encoding="utf-8") == "# template\nA=default\n"
assert stat.S_IMODE(target.stat().st_mode) == 0o600
def test_sync_appends_only_final_extra_definitions_in_original_order(tmp_path: Path) -> None:
"""模板外字段按首次出现顺序追加,重复字段只保留最后一次生效定义。"""
example = tmp_path / ".env.example"
target = tmp_path / ".env"
example.write_text("A=default\n", encoding="utf-8")
target.write_text("EXTRA_B=first\nEXTRA_A=one\nEXTRA_B=final # keep\nA=local\n", encoding="utf-8")
sync_env(example, target)
assert target.read_text(encoding="utf-8") == (
"A=local\n\nEXTRA_B=final # keep\nEXTRA_A=one\n"
)
def test_sync_is_idempotent(tmp_path: Path) -> None:
"""对已同步文件再次运行不会产生空行、排序或内容漂移。"""
example = tmp_path / ".env.example"
target = tmp_path / ".env"
example.write_text("# group\nA=default\nB=default\n", encoding="utf-8")
target.write_text("B=local\nEXTRA=value\n", encoding="utf-8")
sync_env(example, target)
first = target.read_bytes()
sync_env(example, target)
assert target.read_bytes() == first
def test_sync_preserves_crlf_from_template(tmp_path: Path) -> None:
"""磁盘读写不应把模板的 CRLF 换行静默转换为 LF。"""
example = tmp_path / ".env.example"
target = tmp_path / ".env"
example.write_bytes(b"A=default\r\nB=default\r\n")
target.write_bytes(b"A=local\r\nEXTRA=value\r\n")
sync_env(example, target)
assert target.read_bytes() == b"A=local\r\nB=default\r\n\r\nEXTRA=value\r\n"
def test_merge_preserves_no_assignment_semantics(tmp_path: Path) -> None:
"""合法的无等号声明保持 None 语义,模板声明也能接收已有的普通值。"""
example_path = tmp_path / ".env.example"
env_path = tmp_path / ".env"
assert merge_env_text(
"# group\nA=default # keep\n",
"export A\n",
example_path=example_path,
env_path=env_path,
) == "# group\nA # keep\n"
assert merge_env_text(
"# group\nA # keep\n",
"export A='local value'\n",
example_path=example_path,
env_path=env_path,
) == "# group\nA='local value' # keep\n"
def test_sync_rejects_invalid_env_without_modifying_it(tmp_path: Path) -> None:
"""旧配置含语法错误时中止同步,避免在覆盖时静默丢失无法识别的字段。"""
example = tmp_path / ".env.example"
target = tmp_path / ".env"
example.write_text("A=default\n", encoding="utf-8")
original = b"A=local\nBROKEN LINE\n"
target.write_bytes(original)
with pytest.raises(ValueError, match="无法解析"):
sync_env(example, target)
assert target.read_bytes() == original
def test_sync_rejects_symlink_target(tmp_path: Path) -> None:
"""输出路径为符号链接时拒绝写入,防止脚本越过调用者指定的目标。"""
example = tmp_path / ".env.example"
actual = tmp_path / "actual.env"
target = tmp_path / ".env"
example.write_text("A=default\n", encoding="utf-8")
actual.write_text("A=local\n", encoding="utf-8")
target.symlink_to(actual)
with pytest.raises(ValueError, match="符号链接"):
sync_env(example, target)
assert actual.read_text(encoding="utf-8") == "A=local\n"