Files
aigc-home/calliope-web/src/lib/components/AttachToProject.svelte
T
总工andmultica-agent d882fd62d8 feat(ui): F批——其余页面融合收口与整站规范统一 (HEL-656)
- 移除 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>
2026-09-30 16:29:47 +08:00

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>