- 移除 Calliope 旧壳死代码:/project/[id] 旧四段编辑器页面与 9 个遗留组件 (NavRail/StoryStage/AssetsStage/ScriptStage/QueueStage/ActivityPanel/ JobRow/AssetThumb/ProjectCard),/projects 死页面(308→/ 已有) - 素材库 /library 迁入 AppShell 新壳:a-chip 筛选、ui/Modal、ConfirmDialog 实心红两级删除、窄屏单列;移除 A 时期 308→/ 重定向;导航轨/更多菜单新增入口 - 智能体子系统去杂色:agentPalette 默认紫→中性、回退色板收敛为 avatar 五色族; AgentChat 气泡按 §3.2/3.6(用户 bg-3/Agent bg-2+line)、进行中指示改青、 紫色空态图标清除;AgentComposer 天蓝/金芯片收敛 - 首页/工作台/五面板/StageStepper/PromptBlock/JobChip 类名收敛到全局 a-btn/ a-input/a-tag/field;Stepper 补金色当前下划+已确认✓;镜头序号按规范用 accent-lo+mono;分页/工作台选择页金色越界改中性或青 - 金色纪律修正:次级文字操作(重试/测试/链接)与必填星标、任务「测试」标签 去金色;「默认」金标签按 §3.9 保留 - 断点收敛:1023/1279/900/780/700/640 → 1180/760(1440 增强保留) - canvas/build-scene 可用级 ≤760 纵向堆叠适配 + 硬编码色/旧 fallback 清理; AppHeader 去 Calliope 品牌与死链 - favicon 收口:static/favicon.png 金色品牌图标,移除 204 stub 与构建豁免 - i18n 七语种 +3 key(shell.navLibrary/library.loadError/library.subtitle)全对齐 - 新增 e2e/fpages.mjs:F 页面三档走查/重定向/五段导入编辑/规范色断言 38 项 Co-authored-by: multica-agent <github@multica.ai>
359 lines
9.8 KiB
Svelte
359 lines
9.8 KiB
Svelte
<script lang="ts">
|
|
import { createMutation, createQuery } from '@tanstack/svelte-query';
|
|
import {
|
|
playgroundApi,
|
|
projects,
|
|
type Character,
|
|
type Location,
|
|
type PlaygroundAttachTarget,
|
|
type Project,
|
|
type Scene,
|
|
} from '$lib/api';
|
|
import { toast } from '$lib/toast';
|
|
import Button from './ui/Button.svelte';
|
|
import Icon from './ui/Icon.svelte';
|
|
import { t } from '$lib/i18n.svelte';
|
|
|
|
interface Props {
|
|
path: string;
|
|
kind: string;
|
|
}
|
|
|
|
let { path, kind }: Props = $props();
|
|
|
|
const isVideo = $derived(
|
|
kind === 'video' || path.toLowerCase().endsWith('.mp4') || path.toLowerCase().endsWith('.webm'),
|
|
);
|
|
|
|
let open = $state(false);
|
|
let projectId = $state<number | ''>('');
|
|
let target = $state<PlaygroundAttachTarget>('character_sheet');
|
|
let characterId = $state<number | ''>('');
|
|
let locationId = $state<number | ''>('');
|
|
let itemName = $state('');
|
|
let sceneId = $state<number | ''>('');
|
|
let message = $state('');
|
|
let characters = $state<Character[]>([]);
|
|
let locations = $state<Location[]>([]);
|
|
let scenes = $state<Scene[]>([]);
|
|
let loadingTargets = $state(false);
|
|
let attached = $state<{ projectId: number; title: string } | null>(null);
|
|
|
|
const projectsQuery = createQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: projects.list,
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!open) return;
|
|
if (isVideo) {
|
|
target = 'scene';
|
|
} else if (target === 'scene') {
|
|
target = 'character_sheet';
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const pid = projectId;
|
|
characterId = '';
|
|
locationId = '';
|
|
sceneId = '';
|
|
message = '';
|
|
characters = [];
|
|
locations = [];
|
|
scenes = [];
|
|
if (!open || pid === '') return;
|
|
|
|
let cancelled = false;
|
|
loadingTargets = true;
|
|
const load = async () => {
|
|
try {
|
|
const assets = await projects.getAssets(Number(pid));
|
|
if (cancelled) return;
|
|
characters = assets.characters;
|
|
locations = assets.locations;
|
|
if (isVideo) {
|
|
const sceneData = await projects.getScenes(Number(pid));
|
|
if (cancelled) return;
|
|
scenes = sceneData.scenes;
|
|
}
|
|
} finally {
|
|
if (!cancelled) loadingTargets = false;
|
|
}
|
|
};
|
|
void load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
});
|
|
|
|
const attachMutation = createMutation({
|
|
mutationFn: () => {
|
|
if (projectId === '') throw new Error(t('attach.pickProject'));
|
|
const payload: Parameters<typeof playgroundApi.attach>[0] = {
|
|
path,
|
|
project_id: Number(projectId),
|
|
target,
|
|
};
|
|
if (target === 'character_sheet') {
|
|
if (characterId === '') throw new Error(t('attach.pickCharacter'));
|
|
payload.character_id = Number(characterId);
|
|
} else if (target === 'location') {
|
|
if (locationId === '') throw new Error(t('attach.pickLocation'));
|
|
payload.location_id = Number(locationId);
|
|
} else if (target === 'item') {
|
|
const name = itemName.trim();
|
|
if (name) payload.name = name;
|
|
} else {
|
|
if (sceneId === '') throw new Error(t('attach.pickScene'));
|
|
payload.scene_id = Number(sceneId);
|
|
}
|
|
return playgroundApi.attach(payload);
|
|
},
|
|
onSuccess: (res) => {
|
|
message = '';
|
|
open = false;
|
|
const p = projectList.find((x) => x.id === res.project_id);
|
|
attached = {
|
|
projectId: res.project_id,
|
|
title: p?.title ?? t('attach.projectFallback', { id: res.project_id }),
|
|
};
|
|
toast.success(t('attach.added'));
|
|
},
|
|
onError: (err) => {
|
|
message = err instanceof Error ? err.message : t('attach.failed');
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
function defaultMiscName(filePath: string): string {
|
|
const base = filePath.replace(/\\/g, '/').split('/').pop() ?? '';
|
|
const stem = base.replace(/\.[^.]+$/, '');
|
|
const stripped = stem.replace(/^[0-9a-f]{8}-/i, '');
|
|
const pretty = (stripped || stem).replace(/[_-]+/g, ' ').trim();
|
|
return pretty || t('attach.newItem');
|
|
}
|
|
|
|
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
|
|
|
|
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
|
|
const stageLink = $derived(
|
|
attached ? `/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}` : '',
|
|
);
|
|
</script>
|
|
|
|
<div class="attach">
|
|
{#if attached && !open}
|
|
<div class="attached" role="status">
|
|
<span class="attached-check" aria-hidden="true"><Icon name="check" size={14} /></span>
|
|
<span class="attached-text">{t('attach.addedTo')} <strong>{attached.title}</strong></span>
|
|
<a class="attached-link" href={stageLink}>{t('attach.viewInProject')}</a>
|
|
<button
|
|
class="attached-dismiss"
|
|
type="button"
|
|
title={t('attach.dismiss')}
|
|
onclick={() => (attached = null)}
|
|
>
|
|
<Icon name="close" size={12} />
|
|
</button>
|
|
</div>
|
|
{:else if !open}
|
|
<Button
|
|
variant="secondary"
|
|
onclick={() => {
|
|
itemName = defaultMiscName(path);
|
|
open = true;
|
|
}}
|
|
>
|
|
{t('attach.addToProject')}
|
|
</Button>
|
|
{:else}
|
|
<div class="panel" role="group" aria-label={t('attach.panelAria')}>
|
|
<label class="field">
|
|
<span class="field-label">{t('attach.projectField')}</span>
|
|
<select class="field-select" bind:value={projectId}>
|
|
<option value="">{t('attach.selectProject')}</option>
|
|
{#each projectList as p}
|
|
<option value={p.id}>{p.title}</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
|
|
{#if !isVideo}
|
|
<label class="field">
|
|
<span class="field-label">{t('attach.addAs')}</span>
|
|
<select class="field-select" bind:value={target}>
|
|
<option value="character_sheet">{t('attach.asCharacterSheet')}</option>
|
|
<option value="location">{t('attach.asLocation')}</option>
|
|
<option value="item">{t('attach.asItem')}</option>
|
|
</select>
|
|
</label>
|
|
|
|
{#if target === 'character_sheet'}
|
|
<label class="field">
|
|
<span class="field-label">{t('attach.characterField')}</span>
|
|
<select class="field-select" bind:value={characterId} disabled={projectId === '' || loadingTargets}>
|
|
<option value="">{t('attach.selectCharacter')}</option>
|
|
{#each characters as c}
|
|
<option value={c.id}>{c.name}</option>
|
|
{/each}
|
|
</select>
|
|
{#if projectId !== '' && !loadingTargets && characters.length === 0}
|
|
<span class="field-hint">{t('attach.noCharacters')}</span>
|
|
{/if}
|
|
</label>
|
|
{:else if target === 'location'}
|
|
<label class="field">
|
|
<span class="field-label">{t('attach.locationField')}</span>
|
|
<select class="field-select" bind:value={locationId} disabled={projectId === '' || loadingTargets}>
|
|
<option value="">{t('attach.selectLocation')}</option>
|
|
{#each locations as loc}
|
|
<option value={loc.id}>{loc.name}</option>
|
|
{/each}
|
|
</select>
|
|
{#if projectId !== '' && !loadingTargets && locations.length === 0}
|
|
<span class="field-hint">{t('attach.noLocations')}</span>
|
|
{/if}
|
|
</label>
|
|
{:else}
|
|
<label class="field">
|
|
<span class="field-label">{t('attach.nameField')}</span>
|
|
<input
|
|
class="field-input"
|
|
type="text"
|
|
bind:value={itemName}
|
|
placeholder={t('attach.newItemPlaceholder')}
|
|
/>
|
|
<span class="field-hint">{t('attach.newItemHint')}</span>
|
|
</label>
|
|
{/if}
|
|
{:else}
|
|
<label class="field">
|
|
<span class="field-label">{t('attach.sceneField')}</span>
|
|
<select class="field-select" bind:value={sceneId} disabled={projectId === '' || loadingTargets}>
|
|
<option value="">{t('attach.selectScene')}</option>
|
|
{#each scenes as s}
|
|
<option value={s.id}>#{s.order_index} {s.heading || t('attach.scene')}</option>
|
|
{/each}
|
|
</select>
|
|
{#if projectId !== '' && !loadingTargets && scenes.length === 0}
|
|
<span class="field-hint">{t('attach.noScenes')}</span>
|
|
{/if}
|
|
</label>
|
|
{/if}
|
|
|
|
<div class="actions">
|
|
<Button
|
|
variant="primary"
|
|
loading={$attachMutation.isPending}
|
|
disabled={projectId === ''}
|
|
onclick={() => $attachMutation.mutate()}
|
|
>
|
|
{t('attach.addBtn')}
|
|
</Button>
|
|
<Button variant="ghost" onclick={() => (open = false)}>{t('common.cancel')}</Button>
|
|
</div>
|
|
{#if message && $attachMutation.isError}
|
|
<p class="err" role="alert">{message}</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.attach {
|
|
margin-top: 10px;
|
|
}
|
|
.attached {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-3);
|
|
padding: var(--sp-3) var(--sp-4);
|
|
background: var(--ok-soft);
|
|
border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
|
|
border-radius: var(--r-md);
|
|
font-size: var(--fs-aux);
|
|
color: var(--text-2);
|
|
}
|
|
.attached-check {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
background: var(--ok);
|
|
color: var(--bg);
|
|
flex-shrink: 0;
|
|
}
|
|
.attached-text {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.attached-text strong {
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
}
|
|
/* 普通链接走 ghost(§1.2):默认 --text-2,悬停 --text,不用金色文字链接。 */
|
|
.attached-link {
|
|
color: var(--text-2);
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.attached-link:hover {
|
|
color: var(--text);
|
|
text-decoration: underline;
|
|
text-underline-offset: 3px;
|
|
}
|
|
.attached-link:focus-visible {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 2px;
|
|
border-radius: var(--r-sm);
|
|
}
|
|
.attached-dismiss {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 22px;
|
|
height: 22px;
|
|
margin-left: auto;
|
|
border: none;
|
|
border-radius: var(--r-sm);
|
|
background: transparent;
|
|
color: var(--text-3);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
.attached-dismiss:hover {
|
|
color: var(--text);
|
|
background: var(--bg-4);
|
|
}
|
|
.attached-dismiss:focus-visible {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 2px;
|
|
}
|
|
.panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-1);
|
|
padding: var(--sp-5);
|
|
background: var(--bg-3);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--r-md);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: var(--sp-3);
|
|
margin-top: var(--sp-1);
|
|
}
|
|
.err {
|
|
margin: var(--sp-1) 0 0;
|
|
font-size: var(--fs-aux);
|
|
color: var(--err);
|
|
}
|
|
</style>
|