Compare commits
2 Commits
2dadeed759
...
850f213aa5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850f213aa5 | ||
|
|
fd93c69245 |
@@ -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
|
||||
|
||||
|
||||
@@ -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` 重新打包。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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)}/>}
|
||||
</>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user