优化移动端智能标签入口

This commit is contained in:
tianma-if committed 2026-08-17 02:54:08 +08:00
1 parent 090b078b31
commit f7cb16b703
5 files changed
+240 -427

No files matched your search

@@ -14,6 +14,7 @@ enum CreateMemoChrome {
static let metaRow = "createMemoMetaRow"
static let notebook = "createMemoNotebookButton"
static let tags = "createMemoTagsInput"
static let smartTags = "createMemoSmartTagsButton"
static let editorFrame = "createMemoEditorFrame"
static let imageTool = "createMemoImageTool"
}
@@ -51,6 +51,12 @@ struct MemoEditView: View {
@State private var showEmptyAiSelectionAlert = false
@State private var aiUndoToken: UUID?
@State private var didImportSharedImages = false
@State private var isSuggestingTags = false
@State private var smartTagsAdded = false
@State private var showSmartTagAlert = false
@State private var smartTagAlertTitle = ""
@State private var smartTagAlertMessage = ""
@State private var smartTagTask: Task<Void, Never>?
private var title: String { get { viewModel.title } nonmutating set { viewModel.title = newValue } }
private var tagsText: String { get { viewModel.tagsText } nonmutating set { viewModel.tagsText = newValue } }
@@ -148,9 +154,7 @@ struct MemoEditView: View {
}
.sheet(isPresented: $showTagPicker) {
MemoTagPickerSheet(
selectedTags: viewModel.tags,
title: title,
contentMarkdown: contentMarkdown
selectedTags: viewModel.tags
) { tags in
tagsText = tags.joined(separator: ", ")
markDirtyAndScheduleSave()
@@ -198,6 +202,11 @@ struct MemoEditView: View {
en: "Select some text in the note body, then tap AI again."
))
}
.alert(smartTagAlertTitle, isPresented: $showSmartTagAlert) {
Button(env.preferences.t("好的", en: "OK"), role: .cancel) {}
} message: {
Text(smartTagAlertMessage)
}
.alert(
env.preferences.t("应用模板?", en: "Apply template?"),
isPresented: $showApplyTemplateConfirm
@@ -291,6 +300,8 @@ struct MemoEditView: View {
await importInitialSharedImagesIfNeeded()
}
.onDisappear {
smartTagTask?.cancel()
smartTagTask = nil
viewModel.cancelScheduledSave()
// Create commit is owned by Back / Done (Android `requestClose` = createMutation).
// Only flush edit sessions, or create-after-image-materialize if still dirty and
@@ -476,6 +487,35 @@ struct MemoEditView: View {
.accessibilityLabel(env.preferences.t("笔记标签", en: "Tags"))
.accessibilityIdentifier(CreateMemoChrome.tags)
Button {
generateAndApplySmartTags()
} label: {
Group {
if isSuggestingTags {
ProgressView()
.controlSize(.small)
.tint(AppTheme.accentStrong)
} else {
Image(systemName: smartTagsAdded ? "checkmark" : "sparkles")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppTheme.accentStrong)
}
}
.frame(width: 34, height: 34)
.background(smartTagsAdded ? AppTheme.accentSoft : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(.plain)
.disabled(isSuggestingTags || busyChrome || viewModel.tags.count >= 24
|| (title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
.opacity((busyChrome || viewModel.tags.count >= 24) ? 0.45 : 1)
.accessibilityLabel(env.preferences.t(
isSuggestingTags ? "正在生成智能标签" : smartTagsAdded ? "智能标签已添加" : "智能标签",
en: isSuggestingTags ? "Generating smart tags" : smartTagsAdded ? "Smart tags added" : "Smart tags"
))
.accessibilityIdentifier(CreateMemoChrome.smartTags)
}
.frame(minHeight: 40)
.accessibilityIdentifier(CreateMemoChrome.metaRow)
@@ -702,6 +742,70 @@ struct MemoEditView: View {
scheduleSave()
}
private func generateAndApplySmartTags() {
guard !isSuggestingTags, !busyChrome, viewModel.tags.count < 24 else { return }
smartTagTask?.cancel()
isSuggestingTags = true
smartTagsAdded = false
smartTagTask = Task { @MainActor in
await pullEditorSnapshotIfPossible()
let currentTags = viewModel.tags
let input = AiTagSuggestionsInput(
title: title,
contentMarkdown: contentMarkdown,
currentTags: currentTags,
locale: env.preferences.isEnglish ? "en-US" : "zh-CN"
)
do {
let response = try await env.session.client.suggestAiTags(input)
try Task.checkCancellation()
let availableSlots = max(0, 24 - currentTags.count)
let additions = Array(response.suggestions
.map(\.name)
.filter { name in
!currentTags.contains { $0.caseInsensitiveCompare(name) == .orderedSame }
}
.prefix(availableSlots))
guard !additions.isEmpty else {
isSuggestingTags = false
smartTagAlertTitle = env.preferences.t("智能标签", en: "Smart tags")
smartTagAlertMessage = env.preferences.t(
"没有找到适合这篇笔记的新标签。",
en: "No useful new tags were found for this note."
)
showSmartTagAlert = true
smartTagTask = nil
return
}
tagsText = (currentTags + additions).joined(separator: ", ")
markDirtyAndScheduleSave()
isSuggestingTags = false
smartTagsAdded = true
try? await Task.sleep(nanoseconds: 4_000_000_000)
guard !Task.isCancelled else { return }
smartTagsAdded = false
smartTagTask = nil
} catch is CancellationError {
isSuggestingTags = false
} catch let apiError as APIError where apiError.code == "ai_not_configured" {
isSuggestingTags = false
smartTagAlertTitle = env.preferences.t("智能标签生成失败", en: "Couldn't generate smart tags")
smartTagAlertMessage = env.preferences.t(
"请先在“AI 集成”中配置默认模型。",
en: "Configure a model in AI Integrations first."
)
showSmartTagAlert = true
smartTagTask = nil
} catch {
isSuggestingTags = false
smartTagAlertTitle = env.preferences.t("智能标签生成失败", en: "Couldn't generate smart tags")
smartTagAlertMessage = error.localizedDescription
showSmartTagAlert = true
smartTagTask = nil
}
}
}
private func requestApplyTemplate(_ seed: CreateMemoSeed) {
let current = CreateMemoSeed(title: title, contentMarkdown: contentMarkdown, tagsText: tagsText)
if current.hasContent {
@@ -1213,24 +1317,13 @@ private struct MemoTagPickerSheet: View {
@State private var query = ""
@State private var availableTags: [TagSummary] = []
@State private var error: String?
@State private var aiSuggestions: [AiTagSuggestion]?
@State private var selectedAiSuggestions = Set<String>()
@State private var aiError: String?
@State private var isSuggesting = false
@State private var suggestionTask: Task<Void, Never>?
let title: String
let contentMarkdown: String
let onChange: ([String]) -> Void
init(
selectedTags: [String],
title: String,
contentMarkdown: String,
onChange: @escaping ([String]) -> Void
) {
_selection = State(initialValue: selectedTags)
self.title = title
self.contentMarkdown = contentMarkdown
self.onChange = onChange
}
@@ -1295,8 +1388,6 @@ private struct MemoTagPickerSheet: View {
.overlay(RoundedRectangle(cornerRadius: 9).stroke(AppTheme.border, lineWidth: 1))
.padding(.horizontal, 16)
aiSuggestionCard
if let error {
Text(error).font(.system(size: 13)).foregroundStyle(AppTheme.danger)
}
@@ -1337,111 +1428,9 @@ private struct MemoTagPickerSheet: View {
}
}
.task { loadTags() }
.onDisappear {
suggestionTask?.cancel()
suggestionTask = nil
}
}
}
private var aiSuggestionCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 3) {
Label(env.preferences.t("AI 推荐标签", en: "AI tag suggestions"), systemImage: "sparkles")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppTheme.accentText)
Text(env.preferences.t(
"确认后再添加到当前笔记;合适时会优先复用已有标签。",
en: "Review suggestions before adding them. Existing tags are preferred when they fit."
))
.font(.system(size: 12))
.foregroundStyle(AppTheme.accentStrong)
}
Spacer(minLength: 4)
Button {
requestAiSuggestions()
} label: {
HStack(spacing: 5) {
if isSuggesting {
ProgressView().tint(.white).controlSize(.small)
} else {
Image(systemName: "sparkles")
}
Text(env.preferences.t(
aiSuggestions == nil ? "生成建议" : "重新生成",
en: aiSuggestions == nil ? "Suggest" : "Suggest again"
))
}
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 10)
.frame(minHeight: 34)
.background(AppTheme.accentAction)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(isSuggesting || (title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
.opacity(isSuggesting ? 0.6 : 1)
}
if let aiError {
Text(aiError).font(.system(size: 12, weight: .semibold)).foregroundStyle(AppTheme.danger)
}
if let aiSuggestions {
if aiSuggestions.isEmpty {
Text(env.preferences.t("没有找到适合这篇笔记的标签。", en: "No useful tags were found for this note."))
.font(.system(size: 12))
.foregroundStyle(AppTheme.accentStrong)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
ForEach(aiSuggestions) { suggestion in
let alreadySelected = containsTag(suggestion.name, in: selection)
let chosen = alreadySelected || selectedAiSuggestions.contains(suggestion.name)
Button {
toggleAiSuggestion(suggestion.name)
} label: {
HStack(spacing: 4) {
if chosen { Image(systemName: "checkmark") }
Text("#\(suggestion.name) · \(suggestionLabel(suggestion, alreadySelected: alreadySelected))")
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(alreadySelected ? AppTheme.accentStrong : chosen ? Color.white : AppTheme.accentText)
.padding(.horizontal, 10)
.frame(minHeight: 30)
.background(alreadySelected ? AppTheme.accentSoft : chosen ? AppTheme.accentAction : AppTheme.card)
.clipShape(Capsule())
.overlay(Capsule().stroke(AppTheme.accentBorder, lineWidth: 1))
}
.buttonStyle(.plain)
.disabled(alreadySelected)
}
}
}
Button(env.preferences.t(
"添加选中标签(\(selectedAiSuggestions.count))",
en: "Add selected (\(selectedAiSuggestions.count))"
)) {
applyAiSuggestions()
}
.font(.system(size: 12, weight: .bold))
.buttonStyle(.borderedProminent)
.tint(AppTheme.title)
.disabled(selectedAiSuggestions.isEmpty)
}
}
}
.padding(12)
.background(AppTheme.accentSoft)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppTheme.accentBorder, lineWidth: 1))
.padding(.horizontal, 16)
}
private func loadTags() {
guard let scope = env.session.dataScope else { return }
do {
@@ -1472,76 +1461,6 @@ private struct MemoTagPickerSheet: View {
onChange(selection)
}
private func requestAiSuggestions() {
suggestionTask?.cancel()
isSuggesting = true
aiError = nil
let input = AiTagSuggestionsInput(
title: title,
contentMarkdown: contentMarkdown,
currentTags: selection,
locale: env.preferences.isEnglish ? "en-US" : "zh-CN"
)
suggestionTask = Task { @MainActor in
do {
let response = try await env.session.client.suggestAiTags(input)
try Task.checkCancellation()
aiSuggestions = response.suggestions
let availableSlots = max(0, 24 - selection.count)
selectedAiSuggestions = Set(
response.suggestions
.filter { !containsTag($0.name, in: selection) }
.prefix(availableSlots)
.map(\.name)
)
} catch is CancellationError {
return
} catch let apiError as APIError where apiError.code == "ai_not_configured" {
aiSuggestions = nil
selectedAiSuggestions = []
aiError = env.preferences.t(
"请先在“AI 集成”中配置默认模型。",
en: "Configure a model in AI Integrations first."
)
} catch {
aiSuggestions = nil
selectedAiSuggestions = []
aiError = error.localizedDescription
}
isSuggesting = false
suggestionTask = nil
}
}
private func toggleAiSuggestion(_ name: String) {
if selectedAiSuggestions.contains(name) {
selectedAiSuggestions.remove(name)
} else if selection.count + selectedAiSuggestions.count < 24 {
selectedAiSuggestions.insert(name)
}
}
private func applyAiSuggestions() {
for suggestion in aiSuggestions ?? []
where selectedAiSuggestions.contains(suggestion.name)
&& selection.count < 24
&& !containsTag(suggestion.name, in: selection) {
selection.append(suggestion.name)
}
selectedAiSuggestions = []
onChange(selection)
}
private func containsTag(_ name: String, in tags: [String]) -> Bool {
tags.contains { $0.caseInsensitiveCompare(name) == .orderedSame }
}
private func suggestionLabel(_ suggestion: AiTagSuggestion, alreadySelected: Bool) -> String {
if alreadySelected { return env.preferences.t("已添加", en: "Added") }
return suggestion.existing
? env.preferences.t("已有", en: "Existing")
: env.preferences.t("新建", en: "New")
}
}
@@ -100,6 +100,9 @@ final class ChromeParityTests: XCTestCase {
XCTAssertTrue(src.contains("CreateMemoChrome.metaRow"), "missing meta row")
XCTAssertTrue(src.contains("CreateMemoChrome.notebook"), "missing notebook control")
XCTAssertTrue(src.contains("CreateMemoChrome.tags"), "missing tags field")
XCTAssertTrue(src.contains("CreateMemoChrome.smartTags"), "missing one-tap smart tags control")
XCTAssertTrue(src.contains("generateAndApplySmartTags"), "smart tags must generate and apply in one action")
XCTAssertFalse(src.contains("AI 推荐标签"), "tag picker must not retain the multi-step AI suggestion flow")
XCTAssertTrue(src.contains("CreateMemoChrome.editorFrame"), "missing bordered editor")
XCTAssertTrue(src.contains("TipTapWebView"), "editor wiring must remain")
XCTAssertTrue(src.contains("commitCreate") || src.contains("persistDraftOrQueue"), "save path must remain")
+118 -134
View File
@@ -1789,33 +1789,118 @@ const NotebookPickerModal = ({
);
};
const TagPickerModal = ({
const SmartTagButton = ({
client,
contentMarkdown,
disabled = false,
onChange,
selectedTags,
title,
}: {
client: ReturnType<typeof useSession>["client"];
contentMarkdown: string;
disabled?: boolean;
onChange: (tags: string[]) => void;
selectedTags: string[];
title: string;
}) => {
const { resolvedLocale, translate } = useMobileLocale();
const [status, setStatus] = useState<"idle" | "loading" | "success">("idle");
const controllerRef = useRef<AbortController | null>(null);
const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const unavailable = disabled || !client || selectedTags.length >= 24 || (!title.trim() && !contentMarkdown.trim());
useEffect(() => () => {
controllerRef.current?.abort();
if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
}, []);
const generateAndApplyTags = async () => {
if (unavailable || !client) return;
controllerRef.current?.abort();
if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
const controller = new AbortController();
controllerRef.current = controller;
setStatus("loading");
try {
const selectedTagKeys = new Set(selectedTags.map((tag) => tag.toLocaleLowerCase()));
const result = await client.suggestAiTags({
title,
contentMarkdown,
currentTags: selectedTags,
locale: resolvedLocale,
}, controller.signal);
const additions = result.suggestions
.filter((suggestion) => !selectedTagKeys.has(suggestion.name.toLocaleLowerCase()))
.slice(0, Math.max(0, 24 - selectedTags.length))
.map((suggestion) => suggestion.name);
if (additions.length === 0) {
setStatus("idle");
Alert.alert(translate("智能标签"), translate("没有找到适合这篇笔记的新标签。"));
return;
}
onChange(Array.from(new Set([...selectedTags, ...additions])).slice(0, 24));
setStatus("success");
feedbackTimerRef.current = setTimeout(() => {
setStatus("idle");
feedbackTimerRef.current = null;
}, 4000);
} catch (error) {
if (controller.signal.aborted) return;
setStatus("idle");
Alert.alert(
translate("智能标签生成失败"),
error instanceof ApiRequestError && error.code === "ai_not_configured"
? translate("请先在“AI 集成”中配置默认模型。")
: error instanceof Error
? error.message
: translate("AI 标签建议生成失败。")
);
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
}
};
const accessibilityLabel = status === "loading"
? translate("正在生成智能标签")
: status === "success"
? translate("智能标签已添加")
: translate("智能标签");
return (
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
accessibilityState={{ disabled: unavailable || status === "loading" }}
disabled={unavailable || status === "loading"}
onPress={() => void generateAndApplyTags()}
style={[styles.smartTagButton, status === "success" && styles.smartTagButtonSuccess, unavailable && styles.buttonDisabled]}
>
{status === "loading"
? <ActivityIndicator color="#047857" size="small" />
: status === "success"
? <Check color="#047857" size={17} />
: <Sparkles color="#047857" size={17} />}
</Pressable>
);
};
const TagPickerModal = ({
dataScope,
onChange,
onClose,
selectedTags,
title,
visible,
}: {
client: ReturnType<typeof useSession>["client"];
contentMarkdown: string;
dataScope: string;
onChange: (tags: string[]) => void;
onClose: () => void;
selectedTags: string[];
title: string;
visible: boolean;
}) => {
const { resolvedLocale, translate } = useMobileLocale();
const { translate } = useMobileLocale();
const safeAreaInsets = useSafeAreaInsets();
const [searchText, setSearchText] = useState("");
const [aiSuggestions, setAiSuggestions] = useState<Awaited<ReturnType<NonNullable<typeof client>["suggestAiTags"]>>["suggestions"] | null>(null);
const [selectedAiSuggestions, setSelectedAiSuggestions] = useState<string[]>([]);
const [aiError, setAiError] = useState<string | null>(null);
const [aiLoading, setAiLoading] = useState(false);
const aiControllerRef = useRef<AbortController | null>(null);
const tagsQuery = useQuery({
queryKey: ["mobile-tags", dataScope],
queryFn: () => listLocalTags(dataScope),
@@ -1829,18 +1914,9 @@ const TagPickerModal = ({
useEffect(() => {
if (visible) {
setSearchText("");
setAiSuggestions(null);
setSelectedAiSuggestions([]);
setAiError(null);
return;
}
aiControllerRef.current?.abort();
aiControllerRef.current = null;
setAiLoading(false);
}, [visible]);
useEffect(() => () => aiControllerRef.current?.abort(), []);
const commit = (nextTags: string[]) => onChange(Array.from(new Set(nextTags)).slice(0, 24));
const toggleTag = (name: string) => commit(
selectedTags.includes(name) ? selectedTags.filter((tag) => tag !== name) : [...selectedTags, name]
@@ -1851,53 +1927,6 @@ const TagPickerModal = ({
commit([...selectedTags, ...additions]);
setSearchText("");
};
const selectedTagKeys = new Set(selectedTags.map((tag) => tag.toLocaleLowerCase()));
const requestAiSuggestions = async () => {
if (!client || (!title.trim() && !contentMarkdown.trim())) return;
aiControllerRef.current?.abort();
const controller = new AbortController();
aiControllerRef.current = controller;
setAiLoading(true);
setAiError(null);
try {
const result = await client.suggestAiTags({
title,
contentMarkdown,
currentTags: selectedTags,
locale: resolvedLocale,
}, controller.signal);
setAiSuggestions(result.suggestions);
setSelectedAiSuggestions(
result.suggestions
.filter((suggestion) => !selectedTagKeys.has(suggestion.name.toLocaleLowerCase()))
.slice(0, Math.max(0, 24 - selectedTags.length))
.map((suggestion) => suggestion.name)
);
} catch (error) {
if (controller.signal.aborted) return;
setAiSuggestions(null);
setSelectedAiSuggestions([]);
setAiError(
error instanceof ApiRequestError && error.code === "ai_not_configured"
? translate("请先在“AI 集成”中配置默认模型。")
: error instanceof Error
? error.message
: translate("AI 标签建议生成失败。")
);
} finally {
if (aiControllerRef.current === controller) {
aiControllerRef.current = null;
setAiLoading(false);
}
}
};
const toggleAiSuggestion = (name: string) => setSelectedAiSuggestions((current) =>
current.includes(name) ? current.filter((tag) => tag !== name) : [...current, name]
);
const applyAiSuggestions = () => {
commit([...selectedTags, ...selectedAiSuggestions]);
setSelectedAiSuggestions([]);
};
return (
<Modal animationType="fade" onRequestClose={onClose} transparent visible={visible}>
@@ -1947,67 +1976,6 @@ const TagPickerModal = ({
) : null}
</View>
<View accessibilityLabel={translate("AI 推荐标签")} style={styles.tagPickerAiCard}>
<View style={styles.tagPickerAiHeader}>
<View style={styles.tagPickerAiHeaderText}>
<View style={styles.tagPickerAiTitleRow}>
<Sparkles color="#047857" size={17} />
<Text style={styles.tagPickerAiTitle}>{translate("AI 推荐标签")}</Text>
</View>
<Text style={styles.tagPickerAiDescription}>{translate("确认后再添加到当前笔记;合适时会优先复用已有标签。")}</Text>
</View>
<Pressable
accessibilityLabel={translate(aiSuggestions ? "重新生成" : "生成建议")}
accessibilityRole="button"
disabled={aiLoading || !client || (!title.trim() && !contentMarkdown.trim())}
onPress={() => void requestAiSuggestions()}
style={[styles.tagPickerAiGenerateButton, (aiLoading || !client || (!title.trim() && !contentMarkdown.trim())) && styles.buttonDisabled]}
>
{aiLoading ? <ActivityIndicator color="#ffffff" size="small" /> : <Sparkles color="#ffffff" size={15} />}
<Text style={styles.tagPickerAiGenerateText}>{translate(aiSuggestions ? "重新生成" : "生成建议")}</Text>
</Pressable>
</View>
{aiError ? <Text style={styles.tagPickerAiError}>{aiError}</Text> : null}
{aiSuggestions ? (
aiSuggestions.length > 0 ? (
<>
<View style={styles.tagPickerAiSuggestions}>
{aiSuggestions.map((suggestion) => {
const alreadySelected = selectedTagKeys.has(suggestion.name.toLocaleLowerCase());
const chosen = alreadySelected || selectedAiSuggestions.includes(suggestion.name);
return (
<Pressable
key={suggestion.name}
accessibilityLabel={`#${suggestion.name}`}
accessibilityRole="button"
accessibilityState={{ disabled: alreadySelected, selected: chosen }}
disabled={alreadySelected}
onPress={() => toggleAiSuggestion(suggestion.name)}
style={[styles.tagPickerAiSuggestion, chosen && styles.tagPickerAiSuggestionSelected, alreadySelected && styles.tagPickerAiSuggestionAdded]}
>
{chosen ? <Check color={alreadySelected ? "#047857" : "#ffffff"} size={13} /> : null}
<Text style={[styles.tagPickerAiSuggestionText, chosen && styles.tagPickerAiSuggestionTextSelected, alreadySelected && styles.tagPickerAiSuggestionTextAdded]}>
#{suggestion.name} · {translate(alreadySelected ? "已添加" : suggestion.existing ? "已有" : "新建")}
</Text>
</Pressable>
);
})}
</View>
<Pressable
accessibilityLabel={translate(`添加选中标签(${selectedAiSuggestions.length})`)}
accessibilityRole="button"
disabled={selectedAiSuggestions.length === 0}
onPress={applyAiSuggestions}
style={[styles.tagPickerAiApplyButton, selectedAiSuggestions.length === 0 && styles.buttonDisabled]}
>
<Text style={styles.tagPickerAiApplyText}>{translate(`添加选中标签(${selectedAiSuggestions.length})`)}</Text>
</Pressable>
</>
) : <Text style={styles.tagPickerAiDescription}>{translate("没有找到适合这篇笔记的标签。")}</Text>
) : null}
</View>
{tagsQuery.isLoading ? (
<ActivityIndicator color="#16a06e" style={styles.tagPickerLoading} />
) : visibleTags.length === 0 ? (
@@ -2610,6 +2578,16 @@ const CreateMemoModal = ({
</Text>
<ChevronDown color="#94a3b8" size={14} />
</Pressable>
<SmartTagButton
client={client}
contentMarkdown={contentMarkdown}
onChange={(nextTags) => {
setTagsText(nextTags.join(", "));
markDirty();
}}
selectedTags={parseTags(tagsText)}
title={title}
/>
</View>
<View style={styles.createMemoEditorFrame}>
@@ -2632,8 +2610,6 @@ const CreateMemoModal = ({
visible={notebookPickerOpen}
/>
<TagPickerModal
client={client}
contentMarkdown={contentMarkdown}
dataScope={dataScope}
onChange={(nextTags) => {
setTagsText(nextTags.join(", "));
@@ -2641,7 +2617,6 @@ const CreateMemoModal = ({
}}
onClose={() => setTagPickerOpen(false)}
selectedTags={parseTags(tagsText)}
title={title}
visible={tagPickerOpen}
/>
<MobileResourceActions
@@ -3186,6 +3161,18 @@ const RichEditorModal = ({
</Text>
<ChevronDown color="#94a3b8" size={14} />
</Pressable>
<SmartTagButton
client={client}
contentMarkdown={contentMarkdownRef.current}
disabled={saving || uploading}
onChange={(nextTags) => {
setTagsText(nextTags.join(", "));
dirtyRef.current = true;
setDirty(true);
}}
selectedTags={parseTags(tagsText)}
title={title}
/>
</View>
{draftRestored ? <Text style={styles.richEditorDraftNotice}>已恢复上次未完成的本地草稿</Text> : null}
<View style={styles.richEditorFrame}>
@@ -3212,8 +3199,6 @@ const RichEditorModal = ({
visible={notebookPickerOpen}
/>
<TagPickerModal
client={client}
contentMarkdown={contentMarkdownRef.current}
dataScope={createMobileDataScope(session?.baseUrl ?? baseUrl, session?.user?.id)}
onChange={(nextTags) => {
setTagsText(nextTags.join(", "));
@@ -3222,7 +3207,6 @@ const RichEditorModal = ({
}}
onClose={() => setTagPickerOpen(false)}
selectedTags={parseTags(tagsText)}
title={title}
visible={tagPickerOpen}
/>
<MobileResourceActions
+11 -105
View File
@@ -1211,8 +1211,7 @@ const baseWorkspaceStyles = StyleSheet.create({
color: "#64748b",
flex: 1,
fontSize: 15,
lineHeight: 23,
minHeight: 36,
lineHeight: 20,
minWidth: 0,
padding: 0,
},
@@ -1227,6 +1226,16 @@ const baseWorkspaceStyles = StyleSheet.create({
createMemoTagsPlaceholder: {
color: "#94a3b8",
},
smartTagButton: {
alignItems: "center",
borderRadius: 8,
height: 34,
justifyContent: "center",
width: 34,
},
smartTagButtonSuccess: {
backgroundColor: "#ecfdf5",
},
createMemoToolButton: {
alignItems: "center",
borderColor: "transparent",
@@ -1563,109 +1572,6 @@ const baseWorkspaceStyles = StyleSheet.create({
fontSize: 13,
fontWeight: "800",
},
tagPickerAiCard: {
backgroundColor: "#ecfdf5",
borderColor: "#bbf7d0",
borderRadius: 10,
borderWidth: 1,
gap: 10,
marginBottom: 8,
padding: 12,
},
tagPickerAiHeader: {
alignItems: "flex-start",
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
},
tagPickerAiHeaderText: {
flex: 1,
gap: 3,
minWidth: 0,
},
tagPickerAiTitleRow: {
alignItems: "center",
flexDirection: "row",
gap: 5,
},
tagPickerAiTitle: {
color: "#065f46",
fontSize: 14,
fontWeight: "800",
},
tagPickerAiDescription: {
color: "#047857",
fontSize: 12,
lineHeight: 17,
},
tagPickerAiGenerateButton: {
alignItems: "center",
backgroundColor: "#047857",
borderRadius: 8,
flexDirection: "row",
gap: 5,
minHeight: 34,
paddingHorizontal: 10,
},
tagPickerAiGenerateText: {
color: "#ffffff",
fontSize: 12,
fontWeight: "800",
},
tagPickerAiError: {
color: "#be123c",
fontSize: 12,
fontWeight: "700",
},
tagPickerAiSuggestions: {
flexDirection: "row",
flexWrap: "wrap",
gap: 7,
},
tagPickerAiSuggestion: {
alignItems: "center",
backgroundColor: "#ffffff",
borderColor: "#a7f3d0",
borderRadius: 999,
borderWidth: 1,
flexDirection: "row",
gap: 4,
minHeight: 30,
paddingHorizontal: 10,
},
tagPickerAiSuggestionSelected: {
backgroundColor: "#047857",
borderColor: "#047857",
},
tagPickerAiSuggestionAdded: {
backgroundColor: "#d1fae5",
borderColor: "#a7f3d0",
},
tagPickerAiSuggestionText: {
color: "#065f46",
fontSize: 12,
fontWeight: "700",
},
tagPickerAiSuggestionTextSelected: {
color: "#ffffff",
},
tagPickerAiSuggestionTextAdded: {
color: "#047857",
},
tagPickerAiApplyButton: {
alignItems: "center",
alignSelf: "flex-start",
backgroundColor: "#0f172a",
borderRadius: 8,
minHeight: 34,
justifyContent: "center",
paddingHorizontal: 11,
},
tagPickerAiApplyText: {
color: "#ffffff",
fontSize: 12,
fontWeight: "800",
},
tagPickerLoading: {
paddingVertical: 28,
},