Compare commits

..

2 Commits

Author SHA1 Message Date
yuzhe
850f213aa5 docs: 对齐数据库与 COS 配置说明 2026-07-24 16:40:55 +08:00
yuzhe
fd93c69245 fix(management): 联动对象存储连接状态 2026-07-24 16:24:42 +08:00
5 changed files with 15 additions and 19 deletions

View File

@@ -3,7 +3,8 @@ NODE_ENV=development
CORS_ORIGIN=http://localhost:5180
# 正式环境必须填写;本地 SQLite 模式可暂时留空。
DATABASE_URL=postgresql://delivery_desk:local_delivery_desk@127.0.0.1:5432/delivery_desk
DATABASE_URL=
# PostgreSQL 示例postgresql://delivery_desk:replace-with-a-password@127.0.0.1:5432/delivery_desk
PGSSL=disable
PG_POOL_MAX=10

View File

@@ -5,6 +5,6 @@
- 新接口使用 `/api/projects/:projectId/works``/api/works/:workId/rounds``notes``collections``versions` 路由只做一个兼容周期,不再扩展。
- 数据库结构变更必须同时更新 `api/db.ts``db/postgres/schema.sql` 和迁移验证。
- `data/``uploads/``.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
- 本地开发可使用 SQLite正式部署使用 PostgreSQL。COS 配置只通过平台管理界面部署密钥注入。
- 本地开发可使用 SQLite正式部署使用 PostgreSQL。COS 凭证只通过平台管理界面录入;`COS_CONFIG_ENCRYPTION_KEY` 通过部署密钥注入。
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
- Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。

View File

@@ -34,7 +34,7 @@ pnpm dev:start
未配置 `DATABASE_URL` 时使用 `data/app.db`;本地上传文件保存在 `uploads/`。两者均包含运行数据或用户文件,已排除在 Git 之外。
复制 `.env.example``.env` 后配置环境变量。生产环境至少需要 `DATABASE_URL``COS_CONFIG_ENCRYPTION_KEY` 和安全的初始管理员密码。真实 COS 凭证只能通过平台管理界面或部署密钥注入,不能提交到 Git。
复制 `.env.example``.env` 后配置环境变量。生产环境至少需要 `DATABASE_URL``COS_CONFIG_ENCRYPTION_KEY` 和安全的初始管理员密码。真实 COS 凭证只能通过平台管理界面入,不能提交到 Git`COS_CONFIG_ENCRYPTION_KEY` 应通过部署密钥注入
## PostgreSQL 与 Docker

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { CheckCircle2, Cloud, Database, Loader2, LockKeyhole, RadioTower, ShieldCheck } from 'lucide-react';
import type { StorageConfig } from '@shared/types';
import { api, ApiError } from '@/api/client';
@@ -8,10 +8,8 @@ const initialForm = {
path_prefix: 'delivery-desk', secret_id: '', secret_key: '',
};
export default function StorageSettings() {
const [items, setItems] = useState<StorageConfig[]>([]);
export default function StorageSettings({ items, onItemsChange }: { items: StorageConfig[]; onItemsChange: (items: StorageConfig[]) => void }) {
const [form, setForm] = useState(initialForm);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<'save' | 'test' | 'activate' | null>(null);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
@@ -19,12 +17,9 @@ export default function StorageSettings() {
const draft = useMemo(() => items.find((item) => item.status === 'draft'), [items]);
const load = async () => {
setLoading(true);
try { setItems(await api.listStorageConfigs()); }
try { onItemsChange(await api.listStorageConfigs()); }
catch (reason) { setError(messageOf(reason)); }
finally { setLoading(false); }
};
useEffect(() => { void load(); }, []);
const save = async () => {
setBusy('save'); setError(''); setNotice('');
@@ -55,8 +50,6 @@ export default function StorageSettings() {
finally { setBusy(null); }
};
if (loading) return <div className="grid gap-3 py-8">{[1, 2].map((item) => <div key={item} className="h-28 animate-pulse rounded-2xl bg-black/5"/>)}</div>;
return <section className="py-8">
<div className="mb-7"><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#d15f37]">Infrastructure / Object Storage</p><h2 className="mt-2 font-display text-4xl tracking-[-.04em]"> COS</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-black/45">稿</p></div>
{error && <div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowRightLeft, ChevronDown, Copy, Fingerprint, History, KeyRound, Plus, RefreshCw, Search, ShieldCheck, Users, X } from 'lucide-react';
import type { AuditLogEntry, ManagedApiKey, ManagedUser, OperationGroup, Project } from '@shared/types';
import type { AuditLogEntry, ManagedApiKey, ManagedUser, OperationGroup, Project, StorageConfig } from '@shared/types';
import { api, ApiError } from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore';
import StorageSettings from '@/components/StorageSettings';
@@ -28,6 +28,7 @@ export default function ManagementPage() {
const [keys, setKeys] = useState<ManagedApiKey[]>([]);
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [storageConfigs, setStorageConfigs] = useState<StorageConfig[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
@@ -53,11 +54,12 @@ export default function ManagementPage() {
setLoading(true);
setError('');
try {
const [nextAccounts, nextKeys, nextLogs, nextProjects, nextGroups] = await Promise.all([
const [nextAccounts, nextKeys, nextLogs, nextProjects, nextGroups, nextStorageConfigs] = await Promise.all([
api.listManagedUsers(), api.listApiKeys(), api.listAuditLogs(), api.listProjects(),
isPlatform ? api.listGroups() : Promise.resolve([]),
isPlatform ? api.listStorageConfigs() : Promise.resolve([]),
]);
setAccounts(nextAccounts); setKeys(nextKeys); setLogs(nextLogs); setProjects(nextProjects); setGroups(nextGroups);
setAccounts(nextAccounts); setKeys(nextKeys); setLogs(nextLogs); setProjects(nextProjects); setGroups(nextGroups); setStorageConfigs(nextStorageConfigs);
} catch (reason) { setError(messageOf(reason)); }
finally { setLoading(false); }
}, [isPlatform, user]);
@@ -67,9 +69,9 @@ export default function ManagementPage() {
...(isPlatform ? [{ id: 'groups' as const, label: '运营组', count: groups.length }] : []),
{ id: 'accounts' as const, label: '账号', count: accounts.length },
{ id: 'keys' as const, label: 'API Key', count: keys.filter((item) => item.status === 'active').length },
...(isPlatform ? [{ id: 'storage' as const, label: '对象存储', count: 0 }] : []),
...(isPlatform ? [{ id: 'storage' as const, label: '对象存储', count: storageConfigs.some((item) => item.status === 'active') ? '已连接' : '未配置' }] : []),
{ id: 'audit' as const, label: '审计日志', count: logs.length },
], [accounts.length, groups.length, isPlatform, keys, logs.length]);
], [accounts.length, groups.length, isPlatform, keys, logs.length, storageConfigs]);
if (user?.role === 'operator') {
return <main className="mx-auto max-w-3xl px-5 py-24 text-center"><ShieldCheck className="mx-auto text-black/25"/><h1 className="mt-5 font-display text-4xl"></h1><p className="mt-3 text-sm text-black/45"></p></main>;
@@ -111,7 +113,7 @@ export default function ManagementPage() {
{tab === 'groups' && <Groups groups={groups} accounts={accounts} onCreate={() => setPanel('group')} onRename={(item)=>{setRenameGroup(item);setRenameGroupValue(item.name);setPanel('rename_group')}} onShowAccounts={(item) => { setAccountGroupFilter(String(item.id)); setTab('accounts'); }} onReplace={(item) => { setReplaceGroup(item); setReplacementUserId(''); setPreviousAdminAction('demote'); setPanel('replace_admin'); }} onToggle={(item) => { if(item.status==='active'&&!window.confirm(`停用“${item.name}”将影响 ${item.active_user_count} 个启用账号、${item.project_count} 个项目和 ${item.customer_link_count} 个客户访问链接。确认继续吗?`))return;void run(() => api.setGroupStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined)); }}/>}
{tab === 'accounts' && <Accounts accounts={accounts} groups={groups} groupFilter={accountGroupFilter} onGroupFilter={setAccountGroupFilter} currentId={user?.id} currentRole={user?.role} onCreate={() => setPanel('user')} onAudit={(item) => { setAuditUserId(item.id); setTab('audit'); }} onRename={(item) => { setRenameTarget(item); setRenameValue(item.display_name); setPanel('rename'); }} onToggle={(item) => void run(() => api.setUserStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined))} onReset={(item) => { setResetTarget(item); setResetPassword(''); setPanel('reset'); }}/>}
{tab === 'keys' && <ApiKeys items={keys} onCreate={() => { setRevealedKey(''); setCopyState('idle'); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>}
{tab === 'storage' && isPlatform && <StorageSettings/>}
{tab === 'storage' && isPlatform && <StorageSettings items={storageConfigs} onItemsChange={setStorageConfigs}/>}
{tab === 'audit' && <AuditLogs items={logs} filteredUser={auditUserId ? accounts.find((item)=>item.id===auditUserId) : undefined} onClear={() => setAuditUserId(null)}/>}
</>}