<!-- SPEC: PJ2026-010405 云端控制台. -->
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
<script setup lang="ts">
import { Grid2X2, List, PackageOpen, Rows3, Rows4 } from "lucide-vue-next";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { systemAPI, type SkillUploadFileInput } from "@/api";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
import BaseDialog from "@/components/common/BaseDialog.vue";
import ContentViewer from "@/components/common/ContentViewer.vue";
import DataGrid from "@/components/common/DataGrid.vue";
import type { DataGridColumn } from "@/components/common/DataGrid.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import EntityCard from "@/components/common/EntityCard.vue";
import ResourceCollection from "@/components/common/ResourceCollection.vue";
import VirtualList from "@/components/common/VirtualList.vue";
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { useForm } from "@/composables/useForm";
import { useConsoleViewState } from "@/composables/useConsoleViewState";
import { useTableLoader } from "@/composables/useTableLoader";
import type { ApiError, ErrorDiagnostic, SkillFilePayload, SkillRecord, SkillTreeEntry, SkillsResponse } from "@/types";

const columns: DataGridColumn[] = [
  { key: "name", label: "Skill" },
  { key: "source", label: "来源" },
  { key: "version", label: "版本" },
  { key: "files", label: "文件", align: "right" },
  { key: "assembly", label: "AgentRun assembly" },
  { key: "actions", label: "操作", align: "right" }
];

const payload = ref<SkillsResponse | null>(null);
const route = useRoute();
const router = useRouter();
const viewState = useConsoleViewState({ filter: "all" });
const selectedSkill = ref<SkillRecord | null>(null);
const tree = ref<SkillTreeEntry[]>([]);
const treeError = ref<string | null>(null);
const treeApiError = ref<ApiError | null>(null);
const treeDiagnostic = ref<ErrorDiagnostic | null>(null);
const filePreview = ref<SkillFilePayload | null>(null);
const fileError = ref<string | null>(null);
const fileApiError = ref<ApiError | null>(null);
const fileDiagnostic = ref<ErrorDiagnostic | null>(null);
const uploadOpen = ref(false);
const uploadName = ref("");
const uploadFiles = ref<SkillUploadFileInput[]>([]);
const uploadForm = useForm();

const table = useTableLoader<SkillRecord>(async () => {
  const response = await systemAPI.skills();
  if (!response.ok) throw response;
  payload.value = response.data;
  return response.data?.skills ?? [];
});

const rows = computed(() => table.rows.value);
const query = computed({
  get: () => viewState.q.value,
  set: (value: string) => void viewState.update({ q: value.trim() })
});
const sourceFilter = computed({
  get: () => viewState.filter.value || "all",
  set: (value: string) => void viewState.update({ filter: value === "all" ? "" : value })
});
const viewMode = computed<"cards" | "list">({
  get: () => viewState.view.value,
  set: (value) => void viewState.update({ view: value === "cards" ? undefined : value })
});
const density = computed<"compact" | "comfortable">({
  get: () => viewState.density.value,
  set: (value) => void viewState.update({ density: value === "comfortable" ? undefined : value })
});
const filteredRows = computed(() => {
  const needle = query.value.trim().toLowerCase();
  return rows.value.filter((row) => {
    if (sourceFilter.value !== "all" && row.source !== sourceFilter.value) return false;
    return !needle || [row.id, row.name, row.description, row.source, row.version, row.commitId].filter(Boolean).join(" ").toLowerCase().includes(needle);
  });
});
const tableState = computed(() => {
  if (table.loading.value) return rows.value.length ? "refreshing" : "initial-loading";
  if (table.error.value) return rows.value.length ? "partial" : "error";
  return rows.value.length ? "ready" : "empty";
});
const uploadedCount = computed(() => rows.value.filter((row) => row.source === "uploaded").length);
const preinstalledCount = computed(() => rows.value.filter((row) => row.source === "preinstalled").length);
const assembly = computed(() => payload.value?.agentRunAssembly ?? null);
const assemblyResourceBundle = computed(() => assembly.value?.resourceBundle ?? null);
const assemblyPrompt = computed(() => assembly.value?.promptAssembly ?? null);

onMounted(async () => {
  await table.reload();
  await restoreRouteSelection();
});
watch(() => route.fullPath, () => void restoreRouteSelection());

function rowKey(row: SkillRecord): string {
  return row.id;
}

function treeKey(entry: SkillTreeEntry): string {
  return entry.path;
}

async function inspectSkill(row: SkillRecord, updateRoute = true): Promise<void> {
  selectedSkill.value = row;
  tree.value = [];
  filePreview.value = null;
  treeError.value = null;
  treeApiError.value = null;
  treeDiagnostic.value = null;
  fileError.value = null;
  fileApiError.value = null;
  fileDiagnostic.value = null;
  const treeResponse = await systemAPI.skillTree(row.id);
  if (!treeResponse.ok) {
    treeError.value = treeResponse.error ?? `HTTP ${treeResponse.status}`;
    treeApiError.value = treeResponse.apiError ?? null;
    treeDiagnostic.value = treeResponse.diagnostic ?? null;
    return;
  }
  tree.value = treeResponse.data?.tree ?? [];
  await previewFile(row, "SKILL.md", updateRoute);
}

async function previewFile(row: SkillRecord, path = "SKILL.md", updateRoute = true): Promise<void> {
  fileError.value = null;
  fileApiError.value = null;
  fileDiagnostic.value = null;
  const response = await systemAPI.skillFile(row.id, path);
  if (!response.ok) {
    filePreview.value = null;
    fileError.value = response.error ?? `HTTP ${response.status}`;
    fileApiError.value = response.apiError ?? null;
    fileDiagnostic.value = response.diagnostic ?? null;
    return;
  }
  filePreview.value = response.data?.file ?? { path, content: response.data?.content ?? "" };
  if (updateRoute) await router.push({ name: "SkillFile", params: { skillId: row.id, filePath: path.split("/") }, query: route.query });
}

async function onUploadInput(event: Event): Promise<void> {
  const input = event.target as HTMLInputElement;
  const files = Array.from(input.files ?? []);
  uploadFiles.value = await Promise.all(files.slice(0, 80).map(async (file) => ({
    relativePath: file.webkitRelativePath || file.name,
    contentText: await file.text()
  })));
}

async function uploadSkill(): Promise<void> {
  const ok = await uploadForm.submit(async () => {
    const response = await systemAPI.uploadSkill(uploadName.value, uploadFiles.value);
    if (!response.ok) throw response;
    uploadOpen.value = false;
    uploadName.value = "";
    uploadFiles.value = [];
    await table.reload();
    const uploaded = response.data?.skill;
    if (uploaded) await inspectSkill(uploaded);
  }, "Skill uploaded");
  if (!ok) return;
}

function displayDate(value?: string | null): string {
  if (!value) return "-";
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return value;
  return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}

function formatBytes(value?: number): string {
  const bytes = Number(value);
  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

async function restoreRouteSelection(): Promise<void> {
  const skillId = typeof route.params.skillId === "string" ? route.params.skillId : "";
  if (!skillId) {
    selectedSkill.value = null;
    tree.value = [];
    filePreview.value = null;
    return;
  }
  const row = rows.value.find((item) => item.id === skillId);
  if (!row || selectedSkill.value?.id === skillId && filePreview.value?.path === routeFilePath()) return;
  await inspectSkill(row, false);
  const filePath = routeFilePath();
  if (filePath && filePath !== "SKILL.md") await previewFile(row, filePath, false);
}

function routeFilePath(): string {
  const value = route.params.filePath;
  if (Array.isArray(value)) return value.join("/");
  return typeof value === "string" && value ? value : "SKILL.md";
}

</script>

<template>
  <section class="route-stack system-page skills-page">
    <PageCommandBar eyebrow="System" title="Skills" description="技能包管理直接使用 /v1/skills、upload、tree 和 file preview 接口，展示 AgentRun assembly 状态。" />
    <AsyncBoundary :state="tableState" :has-content="Boolean(rows.length)" label="加载 Skills" empty-title="暂无 Skill" empty-description="Skills API 当前未返回任何技能。" @retry="table.reload">
      <template #error>
        <section class="data-panel"><EmptyState title="Skills 加载失败" :description="table.error.value || '请求未完成'" :api-error="table.apiError.value" :diagnostic="table.diagnostic.value" action-label="重试" @action="table.reload" /></section>
      </template>
      <section class="system-summary-grid" aria-label="Skills summary">
        <article class="metric-card"><span>Total</span><strong>{{ rows.length }}</strong><small>{{ preinstalledCount }} preinstalled / {{ uploadedCount }} uploaded</small></article>
        <article class="metric-card"><span>ResourceBundle</span><strong>{{ assemblyResourceBundle?.kind || '-' }}</strong><small>{{ assemblyResourceBundle?.skillsDir || '-' }}</small></article>
        <article class="metric-card"><span>Prompt</span><strong>{{ assemblyPrompt?.status || '-' }}</strong><small>valuesPrinted={{ assemblyPrompt?.valuesPrinted === false ? 'false' : 'unknown' }}</small></article>
        <article class="metric-card"><span>Roots</span><strong>{{ payload?.roots?.uploaded ? 'ready' : 'unknown' }}</strong><small>{{ payload?.roots?.codexAggregation || '-' }}</small></article>
      </section>
      <TablePageLayout title="Skill registry" subtitle="preinstalled + uploaded roots / tree / SKILL.md preview">
        <template #actions>
          <button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button>
          <button class="table-action" type="button" @click="uploadOpen = true">Upload</button>
        </template>
        <template #filters>
          <div class="skills-filter-bar">
            <label><span>来源</span><select v-model="sourceFilter"><option value="all">全部</option><option value="preinstalled">preinstalled</option><option value="uploaded">uploaded</option></select></label>
            <label class="skills-search"><span>搜索</span><input v-model="query" type="search" placeholder="名称、描述、版本或 commit" /></label>
            <ViewModeSwitch :model-value="viewMode" :options="[{ value: 'cards', label: '卡片', icon: Grid2X2 }, { value: 'list', label: '列表', icon: List }]" label="Skill 显示模式" @update:model-value="viewMode = $event as 'cards' | 'list'" />
            <ViewModeSwitch :model-value="density" :options="[{ value: 'comfortable', label: '舒适密度', icon: Rows4 }, { value: 'compact', label: '紧凑密度', icon: Rows3 }]" label="Skill 显示密度" @update:model-value="density = $event as 'compact' | 'comfortable'" />
          </div>
        </template>
        <DataGrid v-if="viewMode === 'list'" :columns="columns" :rows="filteredRows" :row-key="rowKey" :selected-id="selectedSkill?.id" :density="density" empty-text="暂无可用 skill。" @select="inspectSkill">
          <template #cell-name="{ row }">
            <strong>{{ (row as SkillRecord).name || (row as SkillRecord).id }}</strong>
            <small>{{ (row as SkillRecord).description || '-' }}</small>
          </template>
          <template #cell-source="{ row }"><span class="status-pill" :data-status="(row as SkillRecord).source || 'pending'">{{ (row as SkillRecord).source || '-' }}</span></template>
          <template #cell-version="{ row }"><code>{{ (row as SkillRecord).version || (row as SkillRecord).commitId || '-' }}</code><small>{{ displayDate((row as SkillRecord).updatedAt || (row as SkillRecord).createdAt) }}</small></template>
          <template #cell-files="{ row }"><strong>{{ (row as SkillRecord).fileCount ?? 0 }}</strong><small>{{ formatBytes((row as SkillRecord).sizeBytes) }}</small></template>
          <template #cell-assembly="{ row }"><code>{{ (row as SkillRecord).manifestPath || '-' }}</code></template>
          <template #cell-actions="{ row }"><button class="table-action" type="button" @click="inspectSkill(row as SkillRecord)">Tree</button></template>
        </DataGrid>
        <ResourceCollection v-else-if="filteredRows.length" :items="filteredRows" :item-key="rowKey" view="cards" :density="density" :selected-id="selectedSkill?.id" :show-controls="false" aria-label="Skill 卡片" @select="inspectSkill">
          <template #card="{ item: row }">
            <EntityCard
              :icon="PackageOpen"
              :title="row.name || row.id"
              :subtitle="row.description || '未提供描述'"
              :status="row.source || 'pending'"
              :selected="selectedSkill?.id === row.id"
              @select="inspectSkill(row)"
            >
              <dl class="industrial-fact-row">
                <div><dt>版本</dt><dd>{{ row.version || row.commitId || '-' }}</dd></div>
                <div><dt>文件</dt><dd>{{ row.fileCount ?? 0 }} / {{ formatBytes(row.sizeBytes) }}</dd></div>
                <div><dt>更新</dt><dd>{{ displayDate(row.updatedAt || row.createdAt) }}</dd></div>
              </dl>
              <template #footer>
                <code>{{ row.manifestPath || 'manifest unavailable' }}</code>
                <button class="table-action" type="button" @click="inspectSkill(row)">查看目录 →</button>
              </template>
            </EntityCard>
          </template>
        </ResourceCollection>
        <EmptyState v-else title="没有匹配 Skill" description="调整来源或搜索条件；技能事实仍来自同源 Skills API。" />
      </TablePageLayout>

      <section class="system-split-grid skills-detail-grid">
        <section class="data-panel skills-tree-panel">
          <header class="panel-header"><h2>目录树</h2><span class="status-pill" :data-status="treeError ? 'error' : selectedSkill ? 'ready' : 'pending'">{{ selectedSkill?.name || '未选择' }}</span></header>
          <ApiErrorDiagnostic v-if="treeError" :error="treeError" :api-error="treeApiError" :diagnostic="treeDiagnostic" compact />
          <p v-else-if="!selectedSkill" class="muted-box">选择一行 skill 查看 tree 和 SKILL.md。</p>
          <VirtualList v-else :items="tree" :item-key="treeKey" :item-height="34" height="430px" aria-label="Skill 目录树">
            <template #default="{ item: entry }"><div class="skill-tree-row" :data-type="entry.type">
              <button type="button" :disabled="entry.type !== 'file'" @click="selectedSkill && previewFile(selectedSkill, entry.path)">{{ entry.type === 'directory' ? 'dir' : 'file' }}</button>
              <span>{{ entry.path }}</span>
              <small>{{ entry.type === 'file' ? formatBytes(entry.sizeBytes ?? 0) : '' }}</small>
            </div></template>
          </VirtualList>
        </section>
        <section class="data-panel skills-preview-panel">
          <header class="panel-header"><h2>文件预览</h2><code>{{ filePreview?.path || 'SKILL.md' }}</code></header>
          <ApiErrorDiagnostic v-if="fileError" :error="fileError" :api-error="fileApiError" :diagnostic="fileDiagnostic" compact />
          <ContentViewer v-else :content="filePreview?.content || '等待选择 skill 或文件。'" mode="text" :title="filePreview?.path || 'SKILL.md'" :filename="filePreview?.path || 'SKILL.md'" />
        </section>
      </section>
    </AsyncBoundary>

    <BaseDialog :open="uploadOpen" title="Upload skill" description="上传文本型 skill 目录；至少包含 SKILL.md。" wide @close="uploadOpen = false">
      <form class="admin-form" @submit.prevent="uploadSkill">
        <label><span>名称</span><input v-model="uploadName" type="text" placeholder="可选；默认读取 SKILL.md frontmatter"></label>
        <label><span>目录或文件</span><input type="file" multiple webkitdirectory @change="onUploadInput"></label>
        <p class="muted-box">已选择 {{ uploadFiles.length }} 个文本文件；后端会剥离单层顶级目录并写入 uploaded skill root。</p>
        <ApiErrorDiagnostic v-if="uploadForm.error.value" :error="uploadForm.error.value" :api-error="uploadForm.apiError.value" :diagnostic="uploadForm.diagnostic.value" compact />
        <p v-if="uploadForm.notice.value" class="form-notice">{{ uploadForm.notice.value }}</p>
      </form>
      <template #footer>
        <button class="table-action" type="button" :disabled="uploadForm.loading.value" @click="uploadOpen = false">取消</button>
        <button class="table-action" type="button" :disabled="uploadForm.loading.value || uploadFiles.length === 0" @click="uploadSkill">{{ uploadForm.loading.value ? '上传中' : '上传' }}</button>
      </template>
    </BaseDialog>
  </section>
</template>

<style scoped>
.skills-page { min-width: 0; }
.skills-filter-bar { display: grid; grid-template-columns: 180px minmax(240px, 1fr) auto; gap: 8px; align-items: end; }
.skills-filter-bar label { display: grid; gap: 4px; color: #60717a; font-size: 9px; font-weight: 850; letter-spacing: 0; text-transform: uppercase; }
.skills-filter-bar select,
.skills-filter-bar input { width: 100%; height: 34px; border: 1px solid #b8c5cc; border-radius: 6px; background: #fff; padding: 0 9px; color: #1c3038; }
.skills-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 9px; padding: 10px; }
.skills-detail-grid { min-height: 360px; }
.skills-tree-panel,
.skills-preview-panel { min-width: 0; box-shadow: inset 3px 0 #1ba39e; }
.skill-tree-row { display: grid; height: 34px; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #e1e8ea; padding: 0 8px; }
.skill-tree-row button { border: 1px solid #c2cfd4; border-radius: 4px; background: #f7fafb; color: #176d6a; font: 800 9px/1 ui-monospace, monospace; }
.skill-tree-row span { overflow: hidden; font: 700 10px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.skill-tree-row small { color: #6b7c84; font-size: 9px; }
.skills-preview-panel .mini-log { max-height: 500px; overflow: auto; border-color: #284951; background: #172f36; color: #dff8f5; }
@media (max-width: 720px) {
  .skills-filter-bar { grid-template-columns: 1fr; }
  .skills-card-grid { grid-template-columns: 1fr; }
  :deep(.system-summary-grid) { grid-template-columns: 1fr 1fr; }
}
</style>
