feat: enhance personal space experience
This commit is contained in:
parent
7c2cc93585
commit
d0af9755c6
174
modules/personalization_manager.py
Normal file
174
modules/personalization_manager.py
Normal file
@ -0,0 +1,174 @@
|
||||
"""Utilities for managing per-user personalization settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional, Union
|
||||
|
||||
PERSONALIZATION_FILENAME = "personalization.json"
|
||||
MAX_SHORT_FIELD_LENGTH = 20
|
||||
MAX_CONSIDERATION_LENGTH = 50
|
||||
MAX_CONSIDERATION_ITEMS = 10
|
||||
TONE_PRESETS = ["健谈", "幽默", "直言不讳", "鼓励性", "诗意", "企业商务", "打破常规", "同理心"]
|
||||
|
||||
DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
||||
"enabled": False,
|
||||
"self_identify": "",
|
||||
"user_name": "",
|
||||
"profession": "",
|
||||
"tone": "",
|
||||
"considerations": [],
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"PERSONALIZATION_FILENAME",
|
||||
"DEFAULT_PERSONALIZATION_CONFIG",
|
||||
"TONE_PRESETS",
|
||||
"MAX_CONSIDERATION_ITEMS",
|
||||
"load_personalization_config",
|
||||
"save_personalization_config",
|
||||
"ensure_personalization_config",
|
||||
"build_personalization_prompt",
|
||||
"sanitize_personalization_payload",
|
||||
]
|
||||
|
||||
|
||||
PathLike = Union[str, Path]
|
||||
|
||||
|
||||
def _ensure_parent(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _to_path(base: PathLike) -> Path:
|
||||
base_path = Path(base).expanduser()
|
||||
if base_path.is_dir():
|
||||
return base_path / PERSONALIZATION_FILENAME
|
||||
return base_path
|
||||
|
||||
|
||||
def ensure_personalization_config(base_dir: PathLike) -> Dict[str, Any]:
|
||||
"""Ensure the personalization file exists and return its content."""
|
||||
path = _to_path(base_dir)
|
||||
_ensure_parent(path)
|
||||
if not path.exists():
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_PERSONALIZATION_CONFIG, f, ensure_ascii=False, indent=2)
|
||||
return deepcopy(DEFAULT_PERSONALIZATION_CONFIG)
|
||||
return load_personalization_config(base_dir)
|
||||
|
||||
|
||||
def load_personalization_config(base_dir: PathLike) -> Dict[str, Any]:
|
||||
"""Load personalization config; fall back to defaults on errors."""
|
||||
path = _to_path(base_dir)
|
||||
_ensure_parent(path)
|
||||
if not path.exists():
|
||||
return ensure_personalization_config(base_dir)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return sanitize_personalization_payload(data)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# 重置为默认配置,避免错误阻塞
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_PERSONALIZATION_CONFIG, f, ensure_ascii=False, indent=2)
|
||||
return deepcopy(DEFAULT_PERSONALIZATION_CONFIG)
|
||||
|
||||
|
||||
def sanitize_personalization_payload(
|
||||
payload: Optional[Dict[str, Any]],
|
||||
fallback: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Normalize payload structure and clamp field lengths."""
|
||||
base = deepcopy(DEFAULT_PERSONALIZATION_CONFIG)
|
||||
if fallback:
|
||||
base.update(fallback)
|
||||
data = payload or {}
|
||||
|
||||
def _resolve_short_field(key: str) -> str:
|
||||
if key in data:
|
||||
return _sanitize_short_field(data.get(key))
|
||||
return _sanitize_short_field(base.get(key))
|
||||
|
||||
base["enabled"] = bool(data.get("enabled", base["enabled"]))
|
||||
base["self_identify"] = _resolve_short_field("self_identify")
|
||||
base["user_name"] = _resolve_short_field("user_name")
|
||||
base["profession"] = _resolve_short_field("profession")
|
||||
base["tone"] = _resolve_short_field("tone")
|
||||
if "considerations" in data:
|
||||
base["considerations"] = _sanitize_considerations(data.get("considerations"))
|
||||
else:
|
||||
base["considerations"] = _sanitize_considerations(base.get("considerations", []))
|
||||
return base
|
||||
|
||||
|
||||
def save_personalization_config(base_dir: PathLike, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Persist sanitized personalization config and return it."""
|
||||
existing = load_personalization_config(base_dir)
|
||||
config = sanitize_personalization_payload(payload, fallback=existing)
|
||||
path = _to_path(base_dir)
|
||||
_ensure_parent(path)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
return config
|
||||
|
||||
|
||||
def build_personalization_prompt(
|
||||
config: Optional[Dict[str, Any]],
|
||||
include_header: bool = True
|
||||
) -> Optional[str]:
|
||||
"""Generate the personalization prompt text based on config."""
|
||||
if not config or not config.get("enabled"):
|
||||
return None
|
||||
|
||||
lines = []
|
||||
if include_header:
|
||||
lines.append("用户的个性化数据,请回答时务必参照这些信息")
|
||||
|
||||
if config.get("self_identify"):
|
||||
lines.append(f"用户希望你自称:{config['self_identify']}")
|
||||
if config.get("user_name"):
|
||||
lines.append(f"用户希望你称呼为:{config['user_name']}")
|
||||
if config.get("profession"):
|
||||
lines.append(f"用户的职业是:{config['profession']}")
|
||||
if config.get("tone"):
|
||||
lines.append(f"用户希望你使用 {config['tone']} 的语气与TA交流")
|
||||
|
||||
considerations: Iterable[str] = config.get("considerations") or []
|
||||
considerations = [item for item in considerations if item]
|
||||
if considerations:
|
||||
lines.append("用户希望你在回答问题时必须考虑的信息是:")
|
||||
for idx, item in enumerate(considerations, 1):
|
||||
lines.append(f"{idx}. {item}")
|
||||
|
||||
if len(lines) == (1 if include_header else 0):
|
||||
# 没有任何有效内容时不注入
|
||||
return None
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _sanitize_short_field(value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
return text[:MAX_SHORT_FIELD_LENGTH]
|
||||
|
||||
|
||||
def _sanitize_considerations(value: Any) -> list:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
cleaned = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
text = item.strip()
|
||||
if not text:
|
||||
continue
|
||||
cleaned.append(text[:MAX_CONSIDERATION_LENGTH])
|
||||
if len(cleaned) >= MAX_CONSIDERATION_ITEMS:
|
||||
break
|
||||
return cleaned
|
||||
205
static/app.js
205
static/app.js
@ -254,6 +254,24 @@ async function bootstrapApp() {
|
||||
currentConversationId: null,
|
||||
currentConversationTitle: '当前对话',
|
||||
personalPageVisible: false,
|
||||
personalizationLoading: false,
|
||||
personalizationSaving: false,
|
||||
personalizationLoaded: false,
|
||||
personalizationStatus: '',
|
||||
personalizationError: '',
|
||||
personalizationMaxConsiderations: 10,
|
||||
overlayPressActive: false,
|
||||
personalForm: {
|
||||
enabled: false,
|
||||
self_identify: '',
|
||||
user_name: '',
|
||||
profession: '',
|
||||
tone: '',
|
||||
considerations: []
|
||||
},
|
||||
tonePresets: ['健谈', '幽默', '直言不讳', '鼓励性', '诗意', '企业商务', '打破常规', '同理心'],
|
||||
newConsideration: '',
|
||||
draggedConsiderationIndex: null,
|
||||
|
||||
// 搜索功能
|
||||
searchQuery: '',
|
||||
@ -865,11 +883,12 @@ async function bootstrapApp() {
|
||||
lastAction.content += data.content;
|
||||
}
|
||||
this.$forceUpdate();
|
||||
if (lastAction && lastAction.blockId) {
|
||||
this.$nextTick(() => this.scrollThinkingToBottom(lastAction.blockId));
|
||||
} else {
|
||||
this.$nextTick(() => {
|
||||
if (lastAction && lastAction.blockId) {
|
||||
this.scrollThinkingToBottom(lastAction.blockId);
|
||||
}
|
||||
this.conditionalScrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1606,6 +1625,9 @@ async function bootstrapApp() {
|
||||
} else {
|
||||
this.conversations.push(...data.data.conversations);
|
||||
}
|
||||
if (this.currentConversationId) {
|
||||
this.promoteConversationToTop(this.currentConversationId);
|
||||
}
|
||||
this.hasMoreConversations = data.data.has_more;
|
||||
console.log(`已加载 ${this.conversations.length} 个对话`);
|
||||
|
||||
@ -2143,12 +2165,185 @@ async function bootstrapApp() {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
},
|
||||
|
||||
openPersonalPage() {
|
||||
async openPersonalPage() {
|
||||
this.personalPageVisible = true;
|
||||
if (!this.personalizationLoaded && !this.personalizationLoading) {
|
||||
await this.fetchPersonalization();
|
||||
}
|
||||
},
|
||||
|
||||
closePersonalPage() {
|
||||
this.personalPageVisible = false;
|
||||
this.draggedConsiderationIndex = null;
|
||||
this.overlayPressActive = false;
|
||||
},
|
||||
|
||||
handleOverlayPressStart(event) {
|
||||
if (event && event.type === 'mousedown' && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
this.overlayPressActive = true;
|
||||
},
|
||||
|
||||
handleOverlayPressEnd() {
|
||||
if (!this.overlayPressActive) {
|
||||
return;
|
||||
}
|
||||
this.overlayPressActive = false;
|
||||
this.closePersonalPage();
|
||||
},
|
||||
|
||||
handleOverlayPressCancel() {
|
||||
this.overlayPressActive = false;
|
||||
},
|
||||
|
||||
async handleLogout() {
|
||||
try {
|
||||
const resp = await fetch('/logout', { method: 'POST' });
|
||||
let result = {};
|
||||
try {
|
||||
result = await resp.json();
|
||||
} catch (err) {
|
||||
result = {};
|
||||
}
|
||||
if (!resp.ok || (result && result.success === false)) {
|
||||
const message = (result && (result.error || result.message)) || '退出失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
alert(`退出登录失败:${error.message || '请稍后重试'}`);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchPersonalization() {
|
||||
this.personalizationLoading = true;
|
||||
this.personalizationError = '';
|
||||
try {
|
||||
const resp = await fetch('/api/personalization');
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.success) {
|
||||
throw new Error(result.error || '加载失败');
|
||||
}
|
||||
this.applyPersonalizationData(result.data || {});
|
||||
this.personalizationLoaded = true;
|
||||
} catch (error) {
|
||||
this.personalizationError = error.message || '加载失败';
|
||||
alert(`加载个性化配置失败:${this.personalizationError}`);
|
||||
} finally {
|
||||
this.personalizationLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
applyPersonalizationData(data) {
|
||||
this.personalForm = {
|
||||
enabled: !!data.enabled,
|
||||
self_identify: data.self_identify || '',
|
||||
user_name: data.user_name || '',
|
||||
profession: data.profession || '',
|
||||
tone: data.tone || '',
|
||||
considerations: Array.isArray(data.considerations) ? [...data.considerations] : []
|
||||
};
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
clearPersonalizationFeedback() {
|
||||
this.personalizationStatus = '';
|
||||
this.personalizationError = '';
|
||||
},
|
||||
|
||||
async savePersonalization() {
|
||||
if (this.personalizationSaving) {
|
||||
return;
|
||||
}
|
||||
this.personalizationSaving = true;
|
||||
this.personalizationStatus = '';
|
||||
this.personalizationError = '';
|
||||
try {
|
||||
const resp = await fetch('/api/personalization', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.personalForm)
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.success) {
|
||||
throw new Error(result.error || '保存失败');
|
||||
}
|
||||
this.applyPersonalizationData(result.data || {});
|
||||
this.personalizationStatus = '已保存';
|
||||
setTimeout(() => {
|
||||
this.personalizationStatus = '';
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
this.personalizationError = error.message || '保存失败';
|
||||
alert(`保存个性化配置失败:${this.personalizationError}`);
|
||||
} finally {
|
||||
this.personalizationSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
addConsideration() {
|
||||
if (!this.newConsideration) {
|
||||
return;
|
||||
}
|
||||
if (this.personalForm.considerations.length >= this.personalizationMaxConsiderations) {
|
||||
alert(`最多添加 ${this.personalizationMaxConsiderations} 条信息`);
|
||||
return;
|
||||
}
|
||||
this.personalForm.considerations = [
|
||||
...this.personalForm.considerations,
|
||||
this.newConsideration
|
||||
];
|
||||
this.newConsideration = '';
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
removeConsideration(index) {
|
||||
const items = [...this.personalForm.considerations];
|
||||
items.splice(index, 1);
|
||||
this.personalForm.considerations = items;
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
applyTonePreset(preset) {
|
||||
this.personalForm.tone = preset;
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
handleConsiderationDragStart(index, event) {
|
||||
this.draggedConsiderationIndex = index;
|
||||
if (event && event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
},
|
||||
|
||||
handleConsiderationDragOver(index, event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (this.draggedConsiderationIndex === null || this.draggedConsiderationIndex === index) {
|
||||
return;
|
||||
}
|
||||
const items = [...this.personalForm.considerations];
|
||||
const [moved] = items.splice(this.draggedConsiderationIndex, 1);
|
||||
items.splice(index, 0, moved);
|
||||
this.personalForm.considerations = items;
|
||||
this.draggedConsiderationIndex = index;
|
||||
this.clearPersonalizationFeedback();
|
||||
},
|
||||
|
||||
handleConsiderationDrop(index, event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
this.draggedConsiderationIndex = null;
|
||||
},
|
||||
|
||||
handleConsiderationDragEnd() {
|
||||
this.draggedConsiderationIndex = null;
|
||||
},
|
||||
|
||||
async toggleThinkingMode() {
|
||||
|
||||
@ -812,15 +812,157 @@
|
||||
<transition name="personal-page-fade">
|
||||
<div class="personal-page-overlay"
|
||||
v-if="personalPageVisible"
|
||||
@click.self="closePersonalPage">
|
||||
@mousedown.self="handleOverlayPressStart"
|
||||
@mouseup.self="handleOverlayPressEnd"
|
||||
@mouseleave.self="handleOverlayPressCancel"
|
||||
@touchstart.self.prevent="handleOverlayPressStart"
|
||||
@touchend.self.prevent="handleOverlayPressEnd"
|
||||
@touchcancel.self="handleOverlayPressCancel">
|
||||
<div class="personal-page-card">
|
||||
<h2>个人空间</h2>
|
||||
<p>敬请期待,个人页面正在建设中。</p>
|
||||
<button type="button"
|
||||
class="personal-page-close"
|
||||
@click="closePersonalPage">
|
||||
返回工作区
|
||||
</button>
|
||||
<div class="personal-page-header">
|
||||
<div>
|
||||
<h2>个人空间</h2>
|
||||
<p>配置 AI 智能体的个性化偏好</p>
|
||||
</div>
|
||||
<div class="personal-page-actions">
|
||||
<button type="button"
|
||||
class="personal-page-logout"
|
||||
@click="handleLogout">
|
||||
退出登录
|
||||
</button>
|
||||
<button type="button"
|
||||
class="personal-page-close"
|
||||
@click="closePersonalPage">
|
||||
返回工作区
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="personalization-body" v-if="!personalizationLoading">
|
||||
<label class="personal-toggle">
|
||||
<span class="toggle-text">
|
||||
<span class="toggle-title">启用个性化提示</span>
|
||||
<span class="toggle-desc">开启后才会注入您的偏好</span>
|
||||
</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox"
|
||||
v-model="personalForm.enabled"
|
||||
@change="clearPersonalizationFeedback">
|
||||
<span class="switch-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<form class="personal-form" @submit.prevent="savePersonalization">
|
||||
<div class="personalization-sections">
|
||||
<div class="personal-section personal-info">
|
||||
<label class="personal-field">
|
||||
<span>您希望AI智能体怎么自称?</span>
|
||||
<input type="text"
|
||||
v-model.trim="personalForm.self_identify"
|
||||
maxlength="20"
|
||||
placeholder="如:小秘、助理小A"
|
||||
@input="clearPersonalizationFeedback">
|
||||
</label>
|
||||
<label class="personal-field">
|
||||
<span>您希望AI智能体怎么称呼您?</span>
|
||||
<input type="text"
|
||||
v-model.trim="personalForm.user_name"
|
||||
maxlength="20"
|
||||
placeholder="如:Jojo、老师"
|
||||
@input="clearPersonalizationFeedback">
|
||||
</label>
|
||||
<label class="personal-field">
|
||||
<span>您的职业是?</span>
|
||||
<input type="text"
|
||||
v-model.trim="personalForm.profession"
|
||||
maxlength="20"
|
||||
placeholder="如:产品经理、设计师"
|
||||
@input="clearPersonalizationFeedback">
|
||||
</label>
|
||||
<div class="personal-field">
|
||||
<label>
|
||||
<span>您希望AI智能体用何种语气与您交流?</span>
|
||||
<input type="text"
|
||||
v-model.trim="personalForm.tone"
|
||||
maxlength="20"
|
||||
placeholder="请选择或输入语气"
|
||||
@input="clearPersonalizationFeedback">
|
||||
</label>
|
||||
<div class="tone-preset-row">
|
||||
<span>快速填入:</span>
|
||||
<div class="tone-preset-buttons">
|
||||
<button type="button"
|
||||
v-for="preset in tonePresets"
|
||||
:key="preset"
|
||||
@click.prevent="applyTonePreset(preset)">
|
||||
{{ preset }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="personal-right-column">
|
||||
<div class="personal-section personal-considerations">
|
||||
<div class="personal-field">
|
||||
<span>您希望AI智能体在回答问题时必须考虑的信息是?</span>
|
||||
<div class="consideration-input">
|
||||
<input type="text"
|
||||
v-model.trim="newConsideration"
|
||||
maxlength="50"
|
||||
placeholder="输入后点击 + 号添加"
|
||||
@input="clearPersonalizationFeedback">
|
||||
<button type="button"
|
||||
class="consideration-add"
|
||||
:disabled="!newConsideration || personalForm.considerations.length >= personalizationMaxConsiderations"
|
||||
@click="addConsideration">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<ul class="consideration-list" v-if="personalForm.considerations.length">
|
||||
<li v-for="(item, idx) in personalForm.considerations"
|
||||
:key="`consideration-${idx}`"
|
||||
class="consideration-item"
|
||||
draggable="true"
|
||||
@dragstart="handleConsiderationDragStart(idx, $event)"
|
||||
@dragover.prevent="handleConsiderationDragOver(idx, $event)"
|
||||
@drop.prevent="handleConsiderationDrop(idx, $event)"
|
||||
@dragend="handleConsiderationDragEnd">
|
||||
<span class="drag-handle" aria-hidden="true">≡</span>
|
||||
<span class="consideration-text">{{ item }}</span>
|
||||
<button type="button"
|
||||
class="consideration-remove"
|
||||
@click="removeConsideration(idx)">
|
||||
−
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="consideration-hint" v-else>尚未添加任何必备信息</p>
|
||||
<p class="consideration-limit">最多 {{ personalizationMaxConsiderations }} 条,可拖动排序</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="personal-form-actions">
|
||||
<div class="personal-status-group">
|
||||
<transition name="personal-status-fade">
|
||||
<span class="status success"
|
||||
v-if="personalizationStatus">{{ personalizationStatus }}</span>
|
||||
</transition>
|
||||
<transition name="personal-status-fade">
|
||||
<span class="status error"
|
||||
v-if="personalizationError">{{ personalizationError }}</span>
|
||||
</transition>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="primary"
|
||||
:disabled="personalizationSaving"
|
||||
@click="savePersonalization">
|
||||
{{ personalizationSaving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="personalization-loading" v-else>
|
||||
正在加载个性化配置...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
419
static/style.css
419
static/style.css
@ -2558,40 +2558,56 @@ o-files {
|
||||
justify-content: center;
|
||||
z-index: 400;
|
||||
padding: 20px;
|
||||
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||
will-change: opacity, backdrop-filter;
|
||||
}
|
||||
|
||||
.personal-page-card {
|
||||
width: min(90vw, 420px);
|
||||
width: min(95vw, 860px);
|
||||
background: #fffaf4;
|
||||
border-radius: 26px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.25);
|
||||
box-shadow: 0 28px 60px rgba(38, 28, 18, 0.25);
|
||||
padding: 40px 48px;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
text-align: left;
|
||||
color: var(--claude-text);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.personal-page-card h2 {
|
||||
font-size: 26px;
|
||||
margin-bottom: 12px;
|
||||
.personal-page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.personal-page-card p {
|
||||
font-size: 15px;
|
||||
.personal-page-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.personal-page-header h2 {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.personal-page-header p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--claude-text-secondary);
|
||||
margin-bottom: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.personal-page-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 22px;
|
||||
align-self: flex-start;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--claude-accent) 0%, var(--claude-accent-strong) 100%);
|
||||
color: #fffdf8;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 14px 28px rgba(189, 93, 58, 0.2);
|
||||
@ -2603,14 +2619,385 @@ o-files {
|
||||
box-shadow: 0 18px 34px rgba(189, 93, 58, 0.3);
|
||||
}
|
||||
|
||||
.personal-page-logout {
|
||||
align-self: flex-start;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.35);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--claude-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.personal-page-logout:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 24px rgba(38, 28, 18, 0.12);
|
||||
}
|
||||
|
||||
.personalization-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 180px);
|
||||
padding-right: 6px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.personalization-body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.personal-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toggle-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggle-desc {
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #d7d1c5;
|
||||
border-radius: 30px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.switch-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .switch-slider {
|
||||
background: var(--claude-accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .switch-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.personal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.personalization-sections {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.personal-section {
|
||||
flex: 1 1 0;
|
||||
min-width: 240px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(118, 103, 84, 0.25);
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.personal-right-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
flex: 1 1 0;
|
||||
max-width: none;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.personal-right-column .personal-section {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.personal-section.personal-considerations .personal-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.personal-section.personal-considerations .consideration-list {
|
||||
max-height: 260px;
|
||||
min-height: 220px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.personal-section.personal-considerations .consideration-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.personal-section.personal-considerations .consideration-item {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.personalization-sections {
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.personal-right-column {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.personal-field input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.4);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.personal-field input:focus {
|
||||
outline: 2px solid rgba(189, 93, 58, 0.35);
|
||||
border-color: rgba(189, 93, 58, 0.6);
|
||||
}
|
||||
|
||||
.tone-preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.tone-preset-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tone-preset-buttons button {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.4);
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tone-preset-buttons button:hover {
|
||||
border-color: var(--claude-accent);
|
||||
color: var(--claude-accent);
|
||||
}
|
||||
|
||||
.consideration-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.consideration-input input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.consideration-add {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: var(--claude-accent);
|
||||
color: #fffdf8;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.consideration-add:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.consideration-list {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.consideration-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(118, 103, 84, 0.5);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
font-size: 16px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.consideration-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--claude-text);
|
||||
}
|
||||
|
||||
.consideration-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #d64545;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.consideration-hint,
|
||||
.consideration-limit {
|
||||
font-size: 13px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.personal-form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding: 12px 0 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.personal-status-group {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.personal-form-actions .primary {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--claude-accent) 0%, var(--claude-accent-strong) 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.personal-form-actions .primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.personal-form-actions .status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.personal-form-actions .status.success {
|
||||
color: #0f9d58;
|
||||
}
|
||||
|
||||
.personal-form-actions .status.error {
|
||||
color: #d64545;
|
||||
}
|
||||
|
||||
.personal-status-fade-enter-active,
|
||||
.personal-status-fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.personal-status-fade-enter-from,
|
||||
.personal-status-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.personalization-loading {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-page-fade-enter-active,
|
||||
.personal-page-fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||
}
|
||||
|
||||
.personal-page-fade-enter-from,
|
||||
.personal-page-fade-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
|
||||
.personal-page-overlay.personal-page-fade-enter-active .personal-page-card,
|
||||
.personal-page-overlay.personal-page-fade-leave-active .personal-page-card {
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.personal-page-overlay.personal-page-fade-enter-from .personal-page-card,
|
||||
.personal-page-overlay.personal-page-fade-leave-to .personal-page-card {
|
||||
transform: translateY(18px) scale(0.985);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 彩蛋灌水特效 */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user