This commit is contained in:
2026-07-01 09:22:26 +00:00
parent 4aa1dab283
commit aa47a19e98
53 changed files with 4721 additions and 77 deletions
@@ -0,0 +1,174 @@
import { useState } from 'react';
import { Plus, Trash2, Loader2, Key, Eye, EyeOff } from 'lucide-react';
import { usePluginContext } from './client';
import type { S3Credential } from './types';
const API_BASE = '/api/v1/plugin/data_analytics';
interface Props {
credentials: S3Credential[];
loading: boolean;
onChanged: () => void;
}
export function CredentialPanel({ credentials, loading, onChanged }: Props) {
const { client } = usePluginContext();
const [showForm, setShowForm] = useState(false);
const [showSecret, setShowSecret] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
display_name: '',
endpoint_url: '',
region: 'us-east-1',
access_key: '',
secret_key: '',
});
const reset = () => {
setForm({ display_name: '', endpoint_url: '', region: 'us-east-1', access_key: '', secret_key: '' });
setError('');
setShowSecret(false);
};
const submit = async () => {
if (!form.display_name.trim() || !form.access_key.trim() || !form.secret_key.trim()) {
setError('显示名 / Access Key / Secret Key 必填');
return;
}
setBusy(true);
setError('');
try {
await client.post(`${API_BASE}/credentials`, {
display_name: form.display_name.trim(),
endpoint_url: form.endpoint_url.trim() || null,
region: form.region.trim() || 'us-east-1',
access_key: form.access_key,
secret_key: form.secret_key,
});
reset();
setShowForm(false);
onChanged();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
setError(msg || '保存失败');
} finally {
setBusy(false);
}
};
const remove = async (cred_id: string) => {
if (!confirm('确定删除该凭证?删除后该凭证下的任务将无法继续运行。')) return;
try {
await client.delete(`${API_BASE}/credentials/${cred_id}`);
onChanged();
} catch (e) {
console.error(e);
}
};
return (
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Key size={16} className="text-accent" /> S3
</h3>
<p className="text-xs text-text-muted mt-0.5">访 SQLite</p>
</div>
<button
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5"
onClick={() => { setShowForm((s) => !s); setError(''); }}
>
<Plus size={14} /> {showForm ? '取消' : '新增'}
</button>
</div>
{showForm && (
<div className="space-y-3 mb-4 p-4 bg-bg-secondary rounded-xl border border-border-secondary">
<input
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="显示名(如 prod-aws"
value={form.display_name}
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
/>
<input
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="Endpoint URL(可选,自托管 S3 / MinIO 填写)"
value={form.endpoint_url}
onChange={(e) => setForm({ ...form, endpoint_url: e.target.value })}
/>
<input
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="Region(默认 us-east-1"
value={form.region}
onChange={(e) => setForm({ ...form, region: e.target.value })}
/>
<input
className="w-full px-3 py-2 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="Access Key"
value={form.access_key}
onChange={(e) => setForm({ ...form, access_key: e.target.value })}
/>
<div className="relative">
<input
type={showSecret ? 'text' : 'password'}
className="w-full px-3 py-2 pr-10 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
placeholder="Secret Key"
value={form.secret_key}
onChange={(e) => setForm({ ...form, secret_key: e.target.value })}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
onClick={() => setShowSecret((s) => !s)}
>
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{error && <div className="text-xs text-danger">{error}</div>}
<button
className="w-full px-3 py-2 text-sm rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center justify-center gap-2"
onClick={submit}
disabled={busy}
>
{busy && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-8 text-text-muted">
<Loader2 size={20} className="animate-spin" />
</div>
) : credentials.length === 0 ? (
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
</div>
) : (
<div className="space-y-2">
{credentials.map((c) => (
<div
key={c.cred_id}
className="flex items-center justify-between p-3 bg-bg-secondary rounded-xl border border-border-secondary"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-text-primary truncate">{c.display_name}</div>
<div className="text-[11px] text-text-muted font-mono mt-0.5">
{c.endpoint_url || 'aws-s3'} · {c.region} · {c.access_key}
</div>
</div>
<button
className="p-1.5 text-text-muted hover:text-danger transition"
onClick={() => remove(c.cred_id)}
title="删除"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useState } from 'react';
import { BarChart3, Plus, Loader2, ListChecks } from 'lucide-react';
import { usePluginContext } from './client';
import { CredentialPanel } from './CredentialPanel';
import { NewJobDialog } from './NewJobDialog';
import { JobDetail } from './JobDetail';
import type { S3Credential, AnalysisJob } from './types';
const API_BASE = '/api/v1/plugin/data_analytics';
interface Props {
pluginName: string;
}
export function Dashboard({ pluginName }: Props) {
const { client } = usePluginContext();
const [credentials, setCredentials] = useState<S3Credential[]>([]);
const [credLoading, setCredLoading] = useState(true);
const [jobs, setJobs] = useState<AnalysisJob[]>([]);
const [jobLoading, setJobLoading] = useState(true);
const [showNewJob, setShowNewJob] = useState(false);
const [openJobId, setOpenJobId] = useState<string | null>(null);
const [error, setError] = useState('');
const loadCredentials = useCallback(async () => {
setCredLoading(true);
try {
const resp = await client.get<{ credentials: S3Credential[] }>(`${API_BASE}/credentials`);
setCredentials(resp.data.credentials || []);
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
setError(msg || '加载凭证失败');
} finally {
setCredLoading(false);
}
}, [client]);
const loadJobs = useCallback(async () => {
setJobLoading(true);
try {
const resp = await client.get<{ jobs: AnalysisJob[] }>(`${API_BASE}/jobs`);
setJobs(resp.data.jobs || []);
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
setError(msg || '加载任务失败');
} finally {
setJobLoading(false);
}
}, [client]);
useEffect(() => {
loadCredentials();
loadJobs();
}, [loadCredentials, loadJobs]);
// 轮询任务列表,方便看状态变化
useEffect(() => {
const t = setInterval(loadJobs, 5000);
return () => clearInterval(t);
}, [loadJobs]);
return (
<div className="h-full overflow-y-auto p-6 bg-bg-base">
<div className="max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3 pb-2">
<div className="w-10 h-10 rounded-xl bg-accent-light text-accent flex items-center justify-center">
<BarChart3 size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-text-primary"></h2>
<p className="text-xs text-text-muted mt-0.5">
S3 agent python_executor / ray_submit{' '}
<span className="font-mono text-[10px] opacity-70">{pluginName}</span>
</p>
</div>
</div>
{error && (
<div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>
)}
<CredentialPanel credentials={credentials} loading={credLoading} onChanged={loadCredentials} />
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<ListChecks size={16} className="text-accent" />
</h3>
<p className="text-xs text-text-muted mt-0.5"> 5 </p>
</div>
<button
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5 disabled:opacity-50"
onClick={() => setShowNewJob(true)}
disabled={credentials.length === 0}
title={credentials.length === 0 ? '请先添加凭证' : ''}
>
<Plus size={14} />
</button>
</div>
{jobLoading && jobs.length === 0 ? (
<div className="flex items-center justify-center py-8 text-text-muted">
<Loader2 size={20} className="animate-spin" />
</div>
) : jobs.length === 0 ? (
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
</div>
) : (
<div className="space-y-2">
{jobs.map((j) => (
<button
key={j.job_id}
className="w-full text-left p-3 bg-bg-secondary rounded-xl border border-border-secondary hover:border-accent transition flex items-center justify-between gap-3"
onClick={() => setOpenJobId(j.job_id)}
>
<div className="min-w-0 flex-1">
<div className="text-sm text-text-primary truncate">{j.description}</div>
<div className="text-[11px] text-text-muted mt-0.5 font-mono">
{j.job_id.slice(0, 8)} · {j.created_at?.slice(0, 19).replace('T', ' ')}
</div>
</div>
<StatusBadge status={j.status} />
</button>
))}
</div>
)}
</div>
</div>
{showNewJob && (
<NewJobDialog
credentials={credentials}
onClose={() => setShowNewJob(false)}
onCreated={loadJobs}
/>
)}
{openJobId && <JobDetail jobId={openJobId} onClose={() => setOpenJobId(null)} />}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
pending: 'bg-bg-base text-text-muted border-border-primary',
running: 'bg-warning-bg text-warning border-warning/20',
completed: 'bg-success-bg text-success border-success/20',
failed: 'bg-danger-bg text-danger border-danger/20',
};
const cls = map[status] || map.pending;
return (
<span className={`text-[10px] font-medium px-2 py-1 rounded-lg border ${cls} shrink-0`}>
{status}
</span>
);
}
@@ -0,0 +1,174 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, X, Activity } from 'lucide-react';
import { usePluginContext } from './client';
import type { AnalysisJob } from './types';
const API_BASE = '/api/v1/plugin/data_analytics';
interface Props {
jobId: string;
onClose: () => void;
}
interface StreamEvent {
type?: string;
ts?: number;
payload?: unknown;
raw?: string;
}
export function JobDetail({ jobId, onClose }: Props) {
const { client, token, apiBase } = usePluginContext();
const [job, setJob] = useState<AnalysisJob | null>(null);
const [events, setEvents] = useState<StreamEvent[]>([]);
const [loading, setLoading] = useState(true);
const eventBoxRef = useRef<HTMLDivElement | null>(null);
// 初次加载 + 后台轮询
useEffect(() => {
let cancelled = false;
const fetchJob = async () => {
try {
const resp = await client.get<AnalysisJob>(`${API_BASE}/jobs/${jobId}`);
if (!cancelled) setJob(resp.data);
} catch (e) {
console.error('fetch job failed', e);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchJob();
const t = setInterval(fetchJob, 4000);
return () => {
cancelled = true;
clearInterval(t);
};
}, [client, jobId]);
// SSE 事件流(用 fetch + ReadableStream,因为 EventSource 不支持自定义 header
useEffect(() => {
const controller = new AbortController();
const run = async () => {
try {
const url = `${apiBase || ''}${API_BASE}/jobs/${jobId}/stream`;
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
if (!resp.body) return;
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let buf = '';
// eslint-disable-next-line no-constant-condition
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() || '';
for (const part of parts) {
const line = part.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const payload = line.slice(5).trim();
try {
setEvents((prev) => [...prev, JSON.parse(payload)]);
} catch {
setEvents((prev) => [...prev, { raw: payload }]);
}
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') console.error('SSE error', e);
}
};
run();
return () => controller.abort();
}, [apiBase, jobId, token]);
// 自动滚动到底部
useEffect(() => {
if (eventBoxRef.current) {
eventBoxRef.current.scrollTop = eventBoxRef.current.scrollHeight;
}
}, [events]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-3xl max-h-[85vh] bg-bg-card rounded-2xl border border-border-primary shadow-xl flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-border-primary">
<div className="min-w-0">
<h3 className="font-semibold text-text-primary flex items-center gap-2">
<Activity size={16} className="text-accent" />
</h3>
<span className="text-[11px] font-mono text-text-muted">{jobId}</span>
</div>
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-5 space-y-4">
{loading ? (
<div className="flex items-center justify-center py-12 text-text-muted">
<Loader2 size={20} className="animate-spin" />
</div>
) : job ? (
<>
<div className="space-y-2">
<Field label="状态" value={job.task_status || job.status} />
<Field label="描述" value={job.description} />
{job.task_error && <Field label="错误" value={job.task_error} danger />}
</div>
{job.task_result !== undefined && job.task_result !== null && (
<div>
<div className="text-xs text-text-secondary mb-1.5"></div>
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-64 overflow-auto">
{typeof job.task_result === 'string'
? job.task_result
: JSON.stringify(job.task_result, null, 2)}
</pre>
</div>
)}
<div>
<div className="text-xs text-text-secondary mb-1.5">SSE</div>
<div
ref={eventBoxRef}
className="text-[11px] font-mono bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-72 overflow-auto space-y-1"
>
{events.length === 0 ? (
<span className="text-text-muted">()</span>
) : (
events.map((e, i) => (
<div key={i} className="text-text-secondary">
<span className="text-accent">{e.type || 'event'}</span>{' '}
{e.payload !== undefined ? (
<span>{JSON.stringify(e.payload)}</span>
) : e.raw ? (
<span>{e.raw}</span>
) : null}
</div>
))
)}
</div>
</div>
</>
) : (
<div className="text-sm text-text-muted text-center py-8"></div>
)}
</div>
</div>
</div>
);
}
function Field({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
return (
<div className="flex gap-3">
<div className="text-xs text-text-muted w-16 shrink-0 pt-0.5">{label}</div>
<div className={`text-sm flex-1 break-words ${danger ? 'text-danger' : 'text-text-primary'}`}>{value}</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Loader2, Send, X } from 'lucide-react';
import { usePluginContext } from './client';
import type { S3Credential } from './types';
const API_BASE = '/api/v1/plugin/data_analytics';
interface Props {
credentials: S3Credential[];
onClose: () => void;
onCreated: () => void;
}
export function NewJobDialog({ credentials, onClose, onCreated }: Props) {
const { client } = usePluginContext();
const [credId, setCredId] = useState(credentials[0]?.cred_id || '');
const [description, setDescription] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const submit = async () => {
if (!credId) {
setError('请选择 S3 凭证');
return;
}
if (!description.trim()) {
setError('请描述要做的分析');
return;
}
setBusy(true);
setError('');
try {
await client.post(`${API_BASE}/jobs`, {
cred_id: credId,
description: description.trim(),
});
onCreated();
onClose();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
setError(msg || '提交失败');
} finally {
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-lg bg-bg-card rounded-2xl border border-border-primary shadow-xl">
<div className="flex items-center justify-between p-4 border-b border-border-primary">
<h3 className="font-semibold text-text-primary"></h3>
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="p-5 space-y-4">
<div>
<label className="text-xs text-text-secondary block mb-1.5">S3 </label>
{credentials.length === 0 ? (
<div className="text-xs text-warning bg-warning-bg/50 border border-warning/20 rounded-lg p-2">
S3
</div>
) : (
<select
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
value={credId}
onChange={(e) => setCredId(e.target.value)}
>
{credentials.map((c) => (
<option key={c.cred_id} value={c.cred_id}>
{c.display_name} · {c.region}
</option>
))}
</select>
)}
</div>
<div>
<label className="text-xs text-text-secondary block mb-1.5"></label>
<textarea
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent min-h-[120px] resize-y"
placeholder="例如:分析 s3://my-bucket/sales/2026-q1/ 的销售趋势,输出按月汇总"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-[11px] text-text-muted mt-1">
Agent s3_peek/s3_list_objects python_executor ray_submit
</p>
</div>
{error && <div className="text-xs text-danger">{error}</div>}
</div>
<div className="flex items-center justify-end gap-2 p-4 border-t border-border-primary">
<button
className="px-3 py-1.5 text-xs rounded-lg border border-border-primary text-text-secondary hover:text-text-primary"
onClick={onClose}
>
</button>
<button
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center gap-1.5"
onClick={submit}
disabled={busy || credentials.length === 0}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
// vite lib 模式 build 完后写一份 wc-manifest.json,给后端 /ui-manifest 端点读
const distDir = 'dist';
if (!existsSync(distDir)) {
console.error('dist/ not found; run vite build first');
process.exit(1);
}
const files = readdirSync(distDir);
const js = files.find((f) => f.endsWith('.js')) || 'plugin-element.js';
const css = files.filter((f) => f.endsWith('.css'));
const manifest = {
tag: 'plugin-data-analytics',
js,
css,
};
writeFileSync(join(distDir, 'wc-manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
console.log(`wrote dist/wc-manifest.json: ${JSON.stringify(manifest)}`);
@@ -0,0 +1,30 @@
import axios, { type AxiosInstance } from 'axios';
import { createContext, useContext } from 'react';
export interface PluginContextValue {
client: AxiosInstance;
token: string;
apiBase: string;
}
export const PluginContext = createContext<PluginContextValue | null>(null);
export function usePluginContext(): PluginContextValue {
const ctx = useContext(PluginContext);
if (!ctx) throw new Error('PluginContext missing — Web Component not initialized');
return ctx;
}
export function makeClient(token: string, apiBase: string): AxiosInstance {
const c = axios.create({
baseURL: apiBase || undefined,
});
c.interceptors.request.use((cfg) => {
if (token) {
cfg.headers = cfg.headers || {};
(cfg.headers as Record<string, string>).Authorization = `Bearer ${token}`;
}
return cfg;
});
return c;
}
@@ -0,0 +1,61 @@
import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Dashboard } from './Dashboard';
import { PluginContext, makeClient } from './client';
// 把 build 出来的 CSS 当字符串收入,作为 ConstructableStyleSheet 注入到 shadow root
// 既能享受 shadow DOM 的样式隔离,也不需要额外的 fetch 步骤。
import css from './styles.css?inline';
const TAG = 'plugin-data-analytics';
class DataAnalyticsElement extends HTMLElement {
private root?: Root;
private mount?: HTMLDivElement;
static get observedAttributes() {
return ['token', 'api-base'];
}
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
// 用 <style> 注入 CSSConstructableStyleSheet 兼容性更好但 vite 注入字符串更直接)
const style = document.createElement('style');
style.textContent = css;
shadow.appendChild(style);
this.mount = document.createElement('div');
this.mount.style.cssText = 'height:100%;width:100%';
shadow.appendChild(this.mount);
this.root = createRoot(this.mount);
this.render();
}
attributeChangedCallback() {
if (this.root) this.render();
}
disconnectedCallback() {
this.root?.unmount();
this.root = undefined;
}
private render() {
const token = this.getAttribute('token') ?? '';
const apiBase = this.getAttribute('api-base') ?? '';
const client = makeClient(token, apiBase);
this.root!.render(
<React.StrictMode>
<PluginContext.Provider value={{ client, token, apiBase }}>
<Dashboard pluginName="data_analytics" />
</PluginContext.Provider>
</React.StrictMode>,
);
}
}
if (!customElements.get(TAG)) {
customElements.define(TAG, DataAnalyticsElement);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
{
"name": "plugin-data-analytics",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "vite build && node build-manifest.mjs",
"dev": "vite build --watch"
},
"dependencies": {
"axios": "^1.15.1",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.8.0",
"vite": "^8.0.4"
}
}
@@ -0,0 +1,91 @@
@import "tailwindcss";
/* 在 shadow DOM 内 :root 不匹配,用 :host 给 Web Component 自身定义主题 token。
颜色名称跟主前端 frontend/src/index.css 保持一致——这样组件里的 bg-bg-card / text-accent
等类名在插件 build 时也能解析到对应的 var()。 */
@theme {
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-tertiary: var(--bg-tertiary);
--color-bg-card: var(--bg-card);
--color-bg-sidebar: var(--bg-sidebar);
--color-bg-input: var(--bg-input);
--color-bg-hover: var(--bg-hover);
--color-bg-active: var(--bg-active);
--color-bg-base: var(--bg-base);
--color-border-primary: var(--border-primary);
--color-border-secondary: var(--border-secondary);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-tertiary: var(--text-tertiary);
--color-text-muted: var(--text-muted);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-light: var(--accent-light);
--color-danger: var(--danger);
--color-danger-bg: var(--danger-bg);
--color-success: var(--success);
--color-success-bg: var(--success-bg);
--color-warning: var(--warning);
--color-warning-bg: var(--warning-bg);
}
:host {
/* light theme defaults — 跟主前端保持一致 */
--bg-primary: #f2f0ed;
--bg-secondary: #eae8e4;
--bg-tertiary: #e0ddd8;
--bg-card: #faf9f7;
--bg-sidebar: #eae8e4;
--bg-input: #f2f0ed;
--bg-hover: rgba(255, 255, 255, 0.4);
--bg-active: rgba(156, 175, 136, 0.08);
--bg-base: #f2f0ed;
--border-primary: #e0ddd8;
--border-secondary: #eae8e4;
--text-primary: #3d3d3d;
--text-secondary: #5a5a5a;
--text-tertiary: #8c8680;
--text-muted: #b5afa8;
--accent: #9caf88;
--accent-hover: #8a9e78;
--accent-light: rgba(156, 175, 136, 0.12);
--danger: #c4917a;
--danger-bg: rgba(196, 145, 122, 0.08);
--success: #7a8e6a;
--success-bg: rgba(122, 142, 106, 0.08);
--warning: #c4a882;
--warning-bg: rgba(196, 168, 130, 0.08);
display: block;
height: 100%;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* 跟随系统/主前端的暗色主题:宿主元素加 [data-theme="dark"] 时切换 */
:host([data-theme="dark"]) {
--bg-primary: #1c1b19;
--bg-secondary: #232220;
--bg-tertiary: #2d2b28;
--bg-card: #252421;
--bg-sidebar: #1e1d1b;
--bg-input: #2d2b28;
--bg-hover: rgba(255, 255, 255, 0.04);
--bg-active: rgba(156, 175, 136, 0.1);
--bg-base: #1c1b19;
--border-primary: rgba(255, 255, 255, 0.06);
--border-secondary: rgba(255, 255, 255, 0.03);
--text-primary: #e8e6e3;
--text-secondary: #c8c5c0;
--text-tertiary: #a09c96;
--text-muted: #7a7772;
--accent: #a8bc94;
--accent-hover: #b8caa6;
--accent-light: rgba(156, 175, 136, 0.15);
--danger: #d4a894;
--danger-bg: rgba(196, 145, 122, 0.1);
--success: #9caf88;
--success-bg: rgba(156, 175, 136, 0.1);
--warning: #c4a882;
--warning-bg: rgba(196, 168, 130, 0.1);
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["*.ts", "*.tsx"]
}
@@ -0,0 +1,25 @@
export interface S3Credential {
cred_id: string;
user_id: string;
display_name: string;
endpoint_url: string | null;
region: string;
access_key: string;
created_at: string | null;
updated_at: string | null;
}
export interface AnalysisJob {
job_id: string;
user_id: string;
cred_id: string | null;
description: string;
status: string;
org_task_id: string | null;
result: string | null;
created_at: string | null;
updated_at: string | null;
task_status?: string;
task_result?: unknown;
task_error?: string | null;
}
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
lib: {
entry: 'index.tsx',
formats: ['es'],
fileName: () => 'plugin-element.js',
},
outDir: 'dist',
emptyOutDir: true,
cssCodeSplit: false,
rollupOptions: {
output: {
assetFileNames: (info) => {
if (info.name && info.name.endsWith('.css')) return 'plugin-element.css';
return 'assets/[name]-[hash][extname]';
},
},
},
},
});