docs(claude): restructure documentation management and add feedback system

- Restructure §8 from "文档沉淀规则" to "文档管理规则" with 4 subsections
  - Add docs/ structure with features/ and knowledge-base/ directories
  - Add feature documentation template with 7 sections (概述/设计初衷/技术设计/预期作用/实际效果/演化路线/头脑风暴)
  - Add feature update trigger matrix (新增/修改/完成/问题/反馈)
  - Add documentation quality checklist
- Add §16
This commit is contained in:
iven
2026-03-16 13:54:03 +08:00
parent 8e630882c7
commit adfd7024df
44 changed files with 10491 additions and 248 deletions

View File

@@ -0,0 +1,40 @@
import { MessageCircle } from 'lucide-react';
import { motion } from 'framer-motion';
import { useFeedbackStore } from './feedbackStore';
import { Button } from '../ui';
interface FeedbackButtonProps {
onClick: () => void;
showCount?: boolean;
}
export function FeedbackButton({ onClick, showCount = true }: FeedbackButtonProps) {
const feedbackItems = useFeedbackStore((state) => state.feedbackItems);
const pendingCount = feedbackItems.filter((f) => f.status === 'pending' || f.status === 'submitted').length;
return (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
variant="ghost"
size="sm"
onClick={onClick}
className="relative flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
<MessageCircle className="w-4 h-4" />
<span className="text-sm">Feedback</span>
{showCount && pendingCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-1 -right-1 w-4 h-4 bg-orange-500 text-white text-[10px] rounded-full flex items-center justify-center"
>
{pendingCount > 9 ? '9+' : pendingCount}
</motion.span>
)}
</Button>
</motion.div>
);
}

View File

@@ -0,0 +1,193 @@
import { format } from 'date-fns';
import { motion, AnimatePresence } from 'framer-motion';
import { Clock, CheckCircle, AlertCircle, Hourglass, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { useFeedbackStore, type FeedbackSubmission, type FeedbackStatus } from './feedbackStore';
import { Button, Badge } from '../ui';
const statusConfig: Record<FeedbackStatus, { label: string; color: string; icon: React.ReactNode }> = {
pending: { label: 'Pending', color: 'text-gray-500', icon: <Clock className="w-4 h-4" /> },
submitted: { label: 'Submitted', color: 'text-blue-500', icon: <CheckCircle className="w-4 h-4" /> },
acknowledged: { label: 'Acknowledged', color: 'text-purple-500', icon: <CheckCircle className="w-4 h-4" /> },
in_progress: { label: 'In Progress', color: 'text-yellow-500', icon: <Hourglass className="w-4 h-4" /> },
resolved: { label: 'Resolved', color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" /> },
};
const typeLabels: Record<string, string> = {
bug: 'Bug Report',
feature: 'Feature Request';
general: 'General Feedback',
};
const priorityLabels: Record<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
};
interface FeedbackHistoryProps {
onViewDetails?: (feedback: FeedbackSubmission) => void;
}
export function FeedbackHistory({ onViewDetails }: FeedbackHistoryProps) {
const { feedbackItems, deleteFeedback, updateFeedbackStatus } = useFeedbackStore();
const [expandedId, setExpandedId] = useState<string | null>(null);
const formatDate = (timestamp: number) => {
return format(new Date(timestamp), 'yyyy-MM-dd HH:mm');
};
const handleDelete = (id: string) => {
if (confirm('Are you sure you want to delete this feedback?')) {
deleteFeedback(id);
}
};
const handleStatusChange = (id: string, newStatus: FeedbackStatus) => {
updateFeedbackStatus(id, newStatus);
};
if (feedbackItems.length === 0) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No feedback submissions yet.</p>
<p className="text-sm mt-1">Click the feedback button to submit your first feedback.</p>
</div>
);
}
return (
<div className="space-y-3">
{feedbackItems.map((feedback) => {
const isExpanded = expandedId === feedback.id;
const statusInfo = statusConfig[feedback.status];
return (
<motion.div
key={feedback.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => setExpandedId(isExpanded ? null : feedback.id)}
>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
{feedback.type === 'bug' && <span className="text-red-500"><AlertCircle className="w-4 h-4" /></span>}
{feedback.type === 'feature' && <span className="text-yellow-500"><ChevronUp className="w-4 h-4" /></span>}
{feedback.type === 'general' && <span className="text-blue-500"><CheckCircle className="w-4 h-4" /></span>}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{feedback.title}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{typeLabels[feedback.type]} - {formatDate(feedback.createdAt)}
</p>
</div>
<Badge variant={feedback.priority === 'high' ? 'error' : feedback.priority === 'medium' ? 'warning' : 'default'}>
{priorityLabels[feedback.priority]}
</Badge>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setExpandedId(isExpanded ? null : feedback.id);
}}
className="text-gray-400 hover:text-gray-600 p-1"
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</div>
{/* Expandable Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-4 pb-3 border-t border-gray-100 dark:border-gray-700"
>
<div className="space-y-3">
{/* Description */}
<div>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description</h5>
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{feedback.description}
</p>
</div>
{/* Attachments */}
{feedback.attachments.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Attachments ({feedback.attachments.length})
</h5>
<div className="flex flex-wrap gap-2 mt-1">
{feedback.attachments.map((att, idx) => (
<span
key={idx}
className="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded"
>
{att.name}
</span>
))}
</div>
</div>
)}
{/* Metadata */}
<div>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">System Info</h5>
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p>App Version: {feedback.metadata.appVersion}</p>
<p>OS: {feedback.metadata.os}</p>
<p>Submitted: {format(feedback.createdAt)}</p>
</div>
</div>
{/* Status and Actions */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-2">
<span className={`flex items-center gap-1 text-xs ${statusInfo.color}`}>
{statusInfo.icon}
{statusInfo.label}
</span>
</div>
<div className="flex items-center gap-2">
<select
value={feedback.status}
onChange={(e) => handleStatusChange(feedback.id, e.target.value as FeedbackStatus)}
className="text-xs border border-gray-200 dark:border-gray-600 rounded px-2 py-1 bg-white dark:bg-gray-800"
>
<option value="pending">Pending</option>
<option value="submitted">Submitted</option>
<option value="acknowledged">Acknowledged</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(feedback.id)}
className="text-red-500 hover:text-red-600"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,292 @@
import { useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Send, Bug, Lightbulb, MessageSquare, AlertCircle, Upload, Trash2 } from 'lucide-react';
import { useFeedbackStore, type FeedbackType, type FeedbackPriority } from './feedbackStore';
import { Button } from '../ui';
import { useToast } from '../ui/Toast';
interface FeedbackModalProps {
onClose: () => void;
}
const typeOptions: { value: FeedbackType; label: string; icon: React.ReactNode }[] = [
{ value: 'bug', label: 'Bug Report', icon: <Bug className="w-4 h-4" /> },
{ value: 'feature', label: 'Feature Request', icon: <Lightbulb className="w-4 h-4" /> },
{ value: 'general', label: 'General Feedback', icon: <MessageSquare className="w-4 h-4" /> },
];
const priorityOptions: { value: FeedbackPriority; label: string; color: string }[] = [
{ value: 'low', label: 'Low', color: 'text-gray-500' },
{ value: 'medium', label: 'Medium', color: 'text-yellow-600' },
{ value: 'high', label: 'High', color: 'text-red-500' },
];
export function FeedbackModal({ onClose }: FeedbackModalProps) {
const { submitFeedback, isLoading, error } = useFeedbackStore();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [type, setType] = useState<FeedbackType>('bug');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<FeedbackPriority>('medium');
const [attachments, setAttachments] = useState<File[]>([]);
const handleSubmit = async () => {
if (!title.trim() || !description.trim()) {
toast('Please fill in title and description', 'warning');
return;
}
// Convert files to base64 for storage
const processedAttachments = await Promise.all(
attachments.map(async (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
name: file.name,
type: file.type,
size: file.size,
data: reader.result as string,
});
};
reader.readAsDataURL(file);
});
})
);
try {
const result = await submitFeedback({
type,
title: title.trim(),
description: description.trim(),
priority,
attachments: processedAttachments,
metadata: {
appVersion: '0.0.0',
os: navigator.platform,
timestamp: Date.now(),
},
});
if (result) {
toast('Feedback submitted successfully!', 'success');
// Reset form
setTitle('');
setDescription('');
setAttachments([]);
setType('bug');
setPriority('medium');
onClose();
}
} catch (err) {
toast('Failed to submit feedback. Please try again.', 'error');
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
// Limit to 5 attachments
const newFiles = [...attachments, ...files].slice(0, 5);
setAttachments(newFiles);
};
const removeAttachment = (index: number) => {
setAttachments(attachments.filter((_, i) => i !== index));
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden"
role="dialog"
aria-modal="true"
aria-labelledby="feedback-title"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 id="feedback-title" className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Submit Feedback
</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-4 space-y-4">
{/* Type Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Feedback Type
</label>
<div className="flex gap-2">
{typeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setType(opt.value)}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all ${
type === opt.value
? 'border-orange-400 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400'
: 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label htmlFor="feedback-title-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title
</label>
<input
id="feedback-title-input"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Brief summary of your feedback"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-400 dark:bg-gray-700 dark:text-gray-100"
maxLength={100}
/>
</div>
{/* Description */}
<div>
<label htmlFor="feedback-desc-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
<textarea
id="feedback-desc-input"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Please describe your feedback in detail. For bugs, include steps to reproduce."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-orange-400 dark:bg-gray-700 dark:text-gray-100 resize-none"
rows={4}
maxLength={2000}
/>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priority
</label>
<div className="flex gap-2">
{priorityOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setPriority(opt.value)}
className={`flex-1 px-3 py-2 rounded-lg border text-sm transition-all ${
priority === opt.value
? 'border-orange-400 bg-orange-50 dark:bg-orange-900/20 font-medium'
: 'border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
} ${opt.color}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Attachments */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Attachments (optional, max 5)
</label>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-3 py-2 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Upload className="w-4 h-4" />
Add Screenshots
</button>
{attachments.length > 0 && (
<div className="mt-2 space-y-1">
{attachments.map((file, index) => (
<div
key={index}
className="flex items-center justify-between px-2 py-1 bg-gray-50 dark:bg-gray-700 rounded text-xs"
>
<span className="truncate text-gray-600 dark:text-gray-300">
{file.name} ({formatFileSize(file.size)})
</span>
<button
onClick={() => removeAttachment(index)}
className="text-gray-400 hover:text-red-500"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
{/* Error Display */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-500 bg-red-50 dark:bg-red-900/20 px-3 py-2 rounded-lg">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={() => { handleSubmit().catch(() => {}); }}
loading={isLoading}
disabled={!title.trim() || !description.trim()}
>
<Send className="w-4 h-4 mr-2" />
Submit
</Button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,143 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Types
export type FeedbackType = 'bug' | 'feature' | 'general';
export type FeedbackPriority = 'low' | 'medium' | 'high';
export type FeedbackStatus = 'pending' | 'submitted' | 'acknowledged' | 'in_progress' | 'resolved';
export interface FeedbackAttachment {
name: string;
type: string;
size: number;
data: string; // base64 encoded
}
export interface FeedbackSubmission {
id: string;
type: FeedbackType;
title: string;
description: string;
priority: FeedbackPriority;
status: FeedbackStatus;
attachments: FeedbackAttachment[];
metadata: {
appVersion: string;
os: string;
timestamp: number;
userAgent?: string;
};
createdAt: number;
updatedAt: number;
}
interface FeedbackState {
feedbackItems: FeedbackSubmission[];
isModalOpen: boolean;
isLoading: boolean;
error: string | null;
}
interface FeedbackActions {
openModal: () => void;
closeModal: () => void;
submitFeedback: (feedback: Omit<FeedbackSubmission, 'id' | 'createdAt' | 'updatedAt' | 'status'>) => Promise<void>;
updateFeedbackStatus: (id: string, status: FeedbackStatus) => void;
deleteFeedback: (id: string) => void;
clearError: () => void;
}
export type FeedbackStore = FeedbackState & FeedbackActions;
const STORAGE_KEY = 'zclaw-feedback-history';
const MAX_FEEDBACK_ITEMS = 100;
// Helper to get app metadata
function getAppMetadata() {
return {
appVersion: '0.0.0',
os: typeof navigator !== 'undefined' ? navigator.platform : 'unknown',
timestamp: Date.now(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
};
}
// Generate unique ID
function generateFeedbackId(): string {
return `fb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export const useFeedbackStore = create<FeedbackStore>()(
persist(
(set, get) => ({
feedbackItems: [],
isModalOpen: false,
isLoading: false,
error: null,
openModal: () => set({ isModalOpen: true }),
closeModal: () => set({ isModalOpen: false }),
submitFeedback: async (feedback) => {
const { feedbackItems } = get();
set({ isLoading: true, error: null });
try {
const newFeedback: FeedbackSubmission = {
...feedback,
id: generateFeedbackId(),
createdAt: Date.now(),
updatedAt: Date.now(),
status: 'submitted',
metadata: {
...feedback.metadata,
...getAppMetadata(),
},
};
// Simulate async submission
await new Promise(resolve => setTimeout(resolve, 300));
// Keep only MAX_FEEDBACK_ITEMS
const updatedItems = [newFeedback, ...feedbackItems].slice(0, MAX_FEEDBACK_ITEMS);
set({
feedbackItems: updatedItems,
isLoading: false,
isModalOpen: false,
});
return newFeedback;
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to submit feedback',
});
throw err;
}
},
updateFeedbackStatus: (id, status) => {
const { feedbackItems } = get();
const updatedItems = feedbackItems.map(item =>
item.id === id
? { ...item, status, updatedAt: Date.now() }
: item
);
set({ feedbackItems: updatedItems });
},
deleteFeedback: (id) => {
const { feedbackItems } = get();
set({
feedbackItems: feedbackItems.filter(item => item.id !== id),
});
},
clearError: () => set({ error: null }),
}),
{
name: STORAGE_KEY,
}
)
);

View File

@@ -0,0 +1,11 @@
export { FeedbackButton } from './FeedbackButton';
export { FeedbackModal } from './FeedbackModal';
export { FeedbackHistory } from './FeedbackHistory';
export {
useFeedbackStore,
type FeedbackSubmission,
type FeedbackType,
type FeedbackPriority,
type FeedbackStatus,
type FeedbackAttachment,
} from './feedbackStore';