| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631 |
- "use client";
- import Link from "next/link";
- import { ChevronDown, ChevronRight, ExternalLink, Hash, RotateCw, Search, UserSearch } from "lucide-react";
- import { useCallback, useEffect, useMemo, useState } from "react";
- import { BusinessDrawer, type BusinessDrawerState } from "@/components/BusinessDrawer";
- import { EmptyState } from "@/components/StateBlocks";
- import type { RawRecord } from "@/lib/api/types";
- import type { BusinessTone } from "@/lib/flow-ledger/business";
- import { asArray, asRecord, maybeText, text } from "@/lib/flow-ledger/format";
- // ---------------------------------------------------------------------------
- // tree model
- // ---------------------------------------------------------------------------
- export type RouteNode = { key: string; action: RawRecord; videos: VideoNode[] };
- export type VideoNode = {
- key: string;
- id: string;
- searchQueryId: string;
- batchKind: string;
- pageNumber: number;
- rankInPage: number;
- video: RawRecord;
- title: string;
- author: string;
- decisionLabel: string;
- decisionAction: string;
- score: string;
- tags: string[];
- routes: RouteNode[];
- };
- export type Forest = { rootVideos: VideoNode[]; queryRoutes: RouteNode[] };
- type BatchGroup = { kind: string; label: string; gate: string; videos: VideoNode[] };
- type QueryGroup = { id: string; label: string; batches: BatchGroup[] };
- // ---------------------------------------------------------------------------
- // component
- // ---------------------------------------------------------------------------
- export function WalkTree({
- actions,
- runId,
- groupByQuery = false,
- queryLabels = {},
- emptyLabel = "本轮没有继续向外扩展"
- }: {
- actions: RawRecord[];
- runId: string;
- groupByQuery?: boolean;
- queryLabels?: Record<string, string>;
- emptyLabel?: string;
- }) {
- const [drawer, setDrawer] = useState<BusinessDrawerState>(null);
- const [expanded, setExpanded] = useState<Set<string>>(new Set());
- const safeActions = useMemo(() => actions.filter(isRecord), [actions]);
- const forest = useMemo(() => buildForest(safeActions), [safeActions]);
- const keyBuckets = useMemo(() => collectKeys(forest), [forest]);
- // default: show every video with its routes (the flow), brought-back videos collapsed.
- useEffect(() => {
- setExpanded(new Set(keyBuckets.videoKeys));
- }, [keyBuckets]);
- const toggle = useCallback((key: string) => {
- setExpanded((prev) => {
- const next = new Set(prev);
- if (next.has(key)) next.delete(key);
- else next.add(key);
- return next;
- });
- }, []);
- const setDepth = useCallback(
- (mode: "collapse" | "routes" | "all") => {
- if (mode === "collapse") setExpanded(new Set());
- else if (mode === "routes") setExpanded(new Set(keyBuckets.videoKeys));
- else setExpanded(new Set(keyBuckets.allKeys));
- },
- [keyBuckets]
- );
- const hasTree = forest.rootVideos.length > 0 || forest.queryRoutes.length > 0;
- const groups = useMemo(
- () => (groupByQuery ? groupRoots(forest.rootVideos, queryLabels) : null),
- [groupByQuery, forest.rootVideos, queryLabels]
- );
- if (!hasTree) return <EmptyState label={emptyLabel} />;
- return (
- <section className="wt-page">
- <div className="wt-controls">
- <span className="wt-controls-label">展开</span>
- <button type="button" onClick={() => setDepth("collapse")}>只看起点</button>
- <button type="button" onClick={() => setDepth("routes")}>看到路线</button>
- <button type="button" onClick={() => setDepth("all")}>全部展开</button>
- </div>
- <div className="wt-tree" role="tree">
- {groups
- ? groups.map((group) => (
- <div className="wt-group" key={group.id || "ungrouped"}>
- <div className="wt-overview-head">
- <span className="wt-root-chip">搜索词</span>
- <strong>{group.label}</strong>
- </div>
- {group.batches.map((batch) => (
- <div className="wt-batch" key={batch.kind || "未分批"}>
- <div className="wt-batch-head">
- <span className="wt-batch-label">{batch.label}</span>
- <span className="wt-batch-count">{batch.videos.length} 条</span>
- {batch.gate ? <span className="wt-batch-gate">· {batch.gate}</span> : null}
- </div>
- <div className="wt-batch-videos">
- {batch.videos.map((node) => (
- <VideoRow
- node={node}
- depth={0}
- hideRootIndex
- runId={runId}
- expanded={expanded}
- onToggle={toggle}
- onOpenDrawer={setDrawer}
- key={node.key}
- />
- ))}
- </div>
- </div>
- ))}
- </div>
- ))
- : forest.rootVideos.map((node, index) => (
- <VideoRow
- node={node}
- depth={0}
- index={index}
- runId={runId}
- expanded={expanded}
- onToggle={toggle}
- onOpenDrawer={setDrawer}
- key={node.key}
- />
- ))}
- {forest.queryRoutes.map((node) => (
- <RouteRow
- node={node}
- depth={0}
- runId={runId}
- expanded={expanded}
- onToggle={toggle}
- onOpenDrawer={setDrawer}
- key={node.key}
- />
- ))}
- </div>
- <BusinessDrawer drawer={drawer} onClose={() => setDrawer(null)} />
- </section>
- );
- }
- // ---------------------------------------------------------------------------
- // rows
- // ---------------------------------------------------------------------------
- function VideoRow({
- node,
- depth,
- index,
- hideRootIndex,
- runId,
- expanded,
- onToggle,
- onOpenDrawer
- }: {
- node: VideoNode;
- depth: number;
- index?: number;
- hideRootIndex?: boolean;
- runId: string;
- expanded: Set<string>;
- onToggle: (key: string) => void;
- onOpenDrawer: (drawer: BusinessDrawerState) => void;
- }) {
- const open = expanded.has(node.key);
- const hasRoutes = node.routes.length > 0;
- const walked = node.routes.filter((r) => isWalked(r.action)).length;
- const tone = decisionTone(node.decisionLabel);
- return (
- <div className="wt-branch" role="treeitem" aria-expanded={hasRoutes ? open : undefined}>
- <div className={`wt-row video ${tone}`}>
- <Toggle open={open} disabled={!hasRoutes} onClick={() => onToggle(node.key)} />
- <button type="button" className="wt-row-main" onClick={() => onOpenDrawer(videoDrawer(node))}>
- <span className={`wt-dot ${tone}`} />
- {depth === 0 && hideRootIndex ? null : (
- <span className="wt-kicker">{depth === 0 ? `起点 ${(index ?? 0) + 1}` : "游走到的"}</span>
- )}
- <span className="wt-title">{node.title}</span>
- <span className="wt-author">{node.author}</span>
- {node.score ? <span className="wt-score">{node.score}</span> : null}
- <span className={`wt-status ${tone}`}>{node.decisionLabel}</span>
- </button>
- <span className="wt-row-right">
- {hasRoutes ? (
- <span className={`wt-badge ${walked ? "go" : "stop"}`}>
- {walked ? `${walked}/${node.routes.length} 路线已游走` : `${node.routes.length} 路线未游走`}
- </span>
- ) : (
- <span className="wt-badge muted">无路线</span>
- )}
- <RowLinks video={node.video} runId={runId} />
- </span>
- </div>
- {open && hasRoutes ? (
- <div className="wt-children">
- {node.routes.map((route) => (
- <RouteRow
- node={route}
- depth={depth + 1}
- runId={runId}
- expanded={expanded}
- onToggle={onToggle}
- onOpenDrawer={onOpenDrawer}
- key={route.key}
- />
- ))}
- </div>
- ) : null}
- </div>
- );
- }
- function RouteRow({
- node,
- depth,
- runId,
- expanded,
- onToggle,
- onOpenDrawer
- }: {
- node: RouteNode;
- depth: number;
- runId: string;
- expanded: Set<string>;
- onToggle: (key: string) => void;
- onOpenDrawer: (drawer: BusinessDrawerState) => void;
- }) {
- const open = expanded.has(node.key);
- const walked = isWalked(node.action);
- const hasVideos = node.videos.length > 0;
- const edge = text(node.action.edge_id, "");
- const targetQuery = asRecord(node.action.target_query);
- return (
- <div className="wt-branch" role="treeitem" aria-expanded={hasVideos ? open : undefined}>
- <div className={`wt-row route ${edgeTone(edge)} ${walked ? "go" : "stop"}`}>
- <Toggle open={open} disabled={!hasVideos} onClick={() => onToggle(node.key)} />
- <button type="button" className="wt-row-main" onClick={() => onOpenDrawer(routeDrawer(node))}>
- <span className="wt-edge-icon">{edgeIcon(edge)}</span>
- <span className="wt-title route">{routeObject(node.action)}</span>
- {targetQuery.text && walked ? <span className="wt-newquery">→《{text(targetQuery.text)}》</span> : null}
- <span className={`wt-walk ${walked ? "go" : "stop"}`}>{walked ? "已游走" : "未游走"}</span>
- {!walked ? <span className="wt-reason">{shortReason(node.action)}</span> : null}
- </button>
- <span className="wt-row-right">
- {hasVideos ? <span className="wt-badge go">游走到 {node.videos.length} 条视频</span> : null}
- {targetQuery.id ? (
- <Link
- className="wt-link"
- href={`/runs/${encodeURIComponent(runId)}/queries/${encodeURIComponent(text(targetQuery.id))}/videos`}
- onClick={(e) => e.stopPropagation()}
- >
- 这些视频
- </Link>
- ) : null}
- </span>
- </div>
- {open && hasVideos ? (
- <div className="wt-children">
- {node.videos.map((video) => (
- <VideoRow
- node={video}
- depth={depth + 1}
- runId={runId}
- expanded={expanded}
- onToggle={onToggle}
- onOpenDrawer={onOpenDrawer}
- key={video.key}
- />
- ))}
- </div>
- ) : null}
- </div>
- );
- }
- function Toggle({ open, disabled, onClick }: { open: boolean; disabled: boolean; onClick: () => void }) {
- if (disabled) return <span className="wt-toggle empty" />;
- return (
- <button type="button" className="wt-toggle" aria-label={open ? "收起" : "展开"} onClick={onClick}>
- {open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
- </button>
- );
- }
- function RowLinks({ video, runId }: { video: RawRecord; runId: string }) {
- // platform content ids (视频号 finder ids) can contain / + = which break path routing;
- // prefer the clean content_discovery_id for the detail route.
- const detailId = maybeText(video.content_discovery_id) || text(video.id, "");
- if (!detailId) return null;
- const platformUrl = text(video.platform_url, "");
- const platformLabel = text(video.platform_label, "原视频");
- return (
- <span className="wt-links">
- <Link className="wt-link" href={`/runs/${encodeURIComponent(runId)}/videos/${encodeURIComponent(detailId)}`} onClick={(e) => e.stopPropagation()}>
- 详情
- </Link>
- {platformUrl ? (
- <a className="wt-link" href={platformUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
- <ExternalLink size={11} />
- {platformLabel}
- </a>
- ) : null}
- </span>
- );
- }
- // ---------------------------------------------------------------------------
- // build
- // ---------------------------------------------------------------------------
- export function buildForest(actions: RawRecord[]): Forest {
- const routable = actions.filter((a) => !isTerminalEdge(text(a.edge_id, "")));
- const routesBySource = new Map<string, RawRecord[]>();
- const sourceIds = new Set<string>();
- const targetIds = new Set<string>();
- const queryRouteActions: RawRecord[] = [];
- routable.forEach((action) => {
- const sourceVideo = asRecord(action.source_video);
- const sid = text(sourceVideo.id, "");
- if (sid) {
- sourceIds.add(sid);
- const bucket = routesBySource.get(sid) || [];
- bucket.push(action);
- routesBySource.set(sid, bucket);
- } else {
- queryRouteActions.push(action);
- }
- asArray(action.target_videos)
- .filter(isRecord)
- .forEach((v) => {
- const vid = text(v.id, "");
- if (vid) targetIds.add(vid);
- });
- });
- const sourceVideoById = new Map<string, RawRecord>();
- routable.forEach((action) => {
- const sv = asRecord(action.source_video);
- const sid = text(sv.id, "");
- if (sid && !sourceVideoById.has(sid)) sourceVideoById.set(sid, sv);
- });
- const buildRoute = (action: RawRecord, parentKey: string, seen: Set<string>): RouteNode => {
- const key = `${parentKey}>r:${actionKey(action)}`;
- const videos = asArray(action.target_videos)
- .filter(isRecord)
- .map((v) => buildVideo(v, key, routesBySource, seen))
- .filter((v): v is VideoNode => v !== null);
- return { key, action, videos };
- };
- function buildVideo(video: RawRecord, parentKey: string, routes: Map<string, RawRecord[]>, seen: Set<string>): VideoNode | null {
- const id = text(video.id, "");
- const key = `${parentKey}>v:${id || Math.random().toString(36).slice(2)}`;
- const decision = asRecord(video.decision);
- const childRoutes = id && !seen.has(id) ? routes.get(id) || [] : [];
- const nextSeen = new Set(seen);
- if (id) nextSeen.add(id);
- return {
- key,
- id,
- searchQueryId: text(video.search_query_id, ""),
- batchKind: text(video.progressive_batch_kind, ""),
- pageNumber: Number(video.progressive_page_number) || 0,
- rankInPage: Number(video.progressive_item_rank_in_page) || 0,
- video,
- title: cleanTitle(text(video.title, "无标题视频")),
- author: text(video.author, "未知作者"),
- decisionLabel: text(decision.label, "未判断"),
- decisionAction: text(decision.action, ""),
- score: scoreText(decision),
- tags: asArray(video.tags).map((t) => text(t, "")).filter(Boolean),
- routes: childRoutes.map((a) => buildRoute(a, key, nextSeen))
- };
- }
- const rootVideos: VideoNode[] = [...sourceIds]
- .filter((id) => !targetIds.has(id))
- .map((id) => buildVideo(sourceVideoById.get(id) || { id }, "root", routesBySource, new Set<string>()))
- .filter((v): v is VideoNode => v !== null);
- const queryRoutes = queryRouteActions.map((a) => buildRoute(a, "root", new Set<string>()));
- return { rootVideos, queryRoutes };
- }
- function groupRoots(rootVideos: VideoNode[], queryLabels: Record<string, string>): QueryGroup[] {
- const map = new Map<string, VideoNode[]>();
- rootVideos.forEach((node) => {
- const qid = node.searchQueryId || "";
- if (!map.has(qid)) map.set(qid, []);
- map.get(qid)!.push(node);
- });
- return [...map.entries()].map(([id, videos]) => ({
- id,
- label: queryLabels[id] || (id ? id : "其它来源"),
- batches: groupBatches(videos)
- }));
- }
- function groupBatches(videos: VideoNode[]): BatchGroup[] {
- const map = new Map<string, VideoNode[]>();
- videos.forEach((node) => {
- const kind = node.batchKind || "";
- if (!map.has(kind)) map.set(kind, []);
- map.get(kind)!.push(node);
- });
- return [...map.entries()]
- .map(([kind, items]) => ({
- kind,
- label: batchLabel(kind),
- gate: batchGate(kind),
- videos: items.slice().sort((a, b) => a.rankInPage - b.rankInPage),
- sortKey: batchSortKey(kind)
- }))
- .sort((a, b) => a.sortKey - b.sortKey)
- .map(({ sortKey: _sortKey, ...rest }) => rest);
- }
- function batchSortKey(kind: string): number {
- if (kind === "initial_top3") return 10;
- if (kind === "page_remainder") return 11;
- const page = /^page_(\d+)$/.exec(kind);
- if (page) return Number(page[1]) * 10;
- if (kind === "author_works") return 9000;
- return 9999;
- }
- function batchLabel(kind: string): string {
- if (kind === "initial_top3") return "前 3 条";
- if (kind === "page_remainder") return "同页剩余";
- const page = /^page_(\d+)$/.exec(kind);
- if (page) return `第 ${Number(page[1])} 页`;
- if (kind === "author_works") return "作者作品";
- return "未分批";
- }
- function batchGate(kind: string): string {
- if (kind === "initial_top3") return "总是看";
- if (kind === "page_remainder") return "命中才看";
- if (/^page_\d+$/.test(kind)) return "命中才翻";
- return "";
- }
- function collectKeys(forest: Forest): { videoKeys: Set<string>; routeKeys: Set<string>; allKeys: Set<string> } {
- const videoKeys = new Set<string>();
- const routeKeys = new Set<string>();
- const walkVideo = (node: VideoNode) => {
- if (node.routes.length) videoKeys.add(node.key);
- node.routes.forEach(walkRoute);
- };
- const walkRoute = (node: RouteNode) => {
- if (node.videos.length) routeKeys.add(node.key);
- node.videos.forEach(walkVideo);
- };
- forest.rootVideos.forEach(walkVideo);
- forest.queryRoutes.forEach(walkRoute);
- return { videoKeys, routeKeys, allKeys: new Set([...videoKeys, ...routeKeys]) };
- }
- // ---------------------------------------------------------------------------
- // drawers
- // ---------------------------------------------------------------------------
- function videoDrawer(node: VideoNode): BusinessDrawerState {
- const decision = asRecord(node.video.decision);
- const scoreItems = asArray(decision.score_items).filter(isRecord);
- const reason = text(decision.reason_label, "");
- return {
- title: node.title,
- sections: [
- {
- label: "这条视频的判断",
- value: `${node.decisionLabel}${reason ? `:${reason}` : ""}`,
- tone: drawerTone(node.decisionLabel)
- },
- scoreItems.length
- ? {
- label: "评分明细",
- items: scoreItems.map((item) => `${text(item.label, "评分")}:${text(item.score_label, "未记录")}`),
- tone: "info"
- }
- : { label: "评分明细", value: "本条没有记录评分。", tone: "info" },
- node.tags.length ? { label: "视频标签", value: node.tags.join(" "), tone: "neutral" } : { label: "作者", value: node.author, tone: "neutral" }
- ]
- };
- }
- function routeDrawer(node: RouteNode): BusinessDrawerState {
- const action = node.action;
- const walked = isWalked(action);
- const detailLines = asArray(action.detail_lines).map((l) => text(l, "")).filter(Boolean);
- return {
- title: `${routeKindLabel(text(action.edge_id, ""))}`,
- sections: [
- {
- label: walked ? "这一步为什么继续" : "这一步为什么停下",
- value: walked
- ? `${routeObject(action)} 通过筛选,继续带回视频。`
- : `${routeObject(action)} 没有继续。原因:${shortReason(action)}。`,
- tone: walked ? "good" : "warn"
- },
- detailLines.length ? { label: "执行明细", items: detailLines, tone: "info" } : { label: "执行明细", value: text(action.summary, "没有更多明细。"), tone: "info" },
- { label: "对流程的影响", value: text(action.impact, walked ? "带回的视频会继续送去判断。" : "这条路线停止,不再带回新视频。"), tone: "neutral" }
- ]
- };
- }
- // ---------------------------------------------------------------------------
- // helpers
- // ---------------------------------------------------------------------------
- function isRecord(value: unknown): value is RawRecord {
- return typeof value === "object" && value !== null;
- }
- function actionKey(action: RawRecord): string {
- return text(action.id || action.walk_action_id || `${text(action.edge_id, "walk")}-${text(action.trigger_label, "")}`);
- }
- function isWalked(action: RawRecord): boolean {
- return text(action.status, "") === "success";
- }
- function isTerminalEdge(edgeId: string): boolean {
- return edgeId === "decision_to_asset" || edgeId === "budget_downgrade" || edgeId === "path_stop";
- }
- function cleanTitle(value: string): string {
- return value.replace(/#[^\s#]+/g, " ").replace(/\s+/g, " ").trim() || value;
- }
- function scoreText(decision: RawRecord): string {
- const label = text(decision.score_label, "");
- if (label && label !== "未记录") return label;
- const score = Number(decision.score);
- return Number.isFinite(score) && score > 0 ? `${Math.round(score)} 分` : "";
- }
- function decisionTone(label: string): string {
- if (label === "入池") return "good";
- if (label === "待复看") return "warn";
- if (label === "淘汰") return "bad";
- if (label.includes("审核拦截")) return "bad";
- if (label.includes("技术问题") || label.includes("重试") || label.includes("补跑")) return "tech";
- return "plain";
- }
- function drawerTone(label: string): BusinessTone {
- if (label === "入池") return "good";
- if (label === "淘汰") return "bad";
- return "warn";
- }
- function edgeTone(edgeId: string): string {
- if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "tag";
- if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "author";
- if (edgeId === "query_next_page") return "page";
- return "plain";
- }
- function edgeIcon(edgeId: string) {
- if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return <Hash size={14} />;
- if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return <UserSearch size={14} />;
- if (edgeId === "query_next_page") return <RotateCw size={14} />;
- return <Search size={14} />;
- }
- function routeKindLabel(edgeId: string): string {
- if (edgeId === "hashtag_to_query" || edgeId === "tag_query") return "从视频标签继续游走";
- if (edgeId === "author_to_works" || edgeId === "author_works" || edgeId === "video_to_author") return "从作者继续游走";
- if (edgeId === "query_next_page") return "翻页继续找";
- return "继续游走";
- }
- function routeObject(action: RawRecord): string {
- const edge = text(action.edge_id, "");
- const raw = cleanBusinessText(text(action.trigger_label, ""));
- if (edge === "query_next_page") return "翻下一页";
- if (edge === "hashtag_to_query" || edge === "tag_query") {
- const target = asRecord(action.target_query);
- if (target.text && isWalked(action)) return `标签`;
- const candidates = raw.replace(/^候选标签:/, "").split("/")[0]?.trim();
- return candidates ? `标签 ${candidates}` : "标签";
- }
- if (edge === "author_to_works" || edge === "author_works" || edge === "video_to_author") {
- return `作者 ${raw.replace(/^作者:/, "") || "待确认"}`;
- }
- return raw || "继续游走";
- }
- function shortReason(action: RawRecord): string {
- const reason = cleanBusinessText(text(action.reason_label, ""));
- // technical retry: the source video failed a technical step (Gemini/play_url/OSS),
- // surface the concrete cause instead of the generic sentence.
- if (reason.includes("重试")) {
- const td = asRecord(asRecord(action.source_video).technical_retry_detail);
- const brief = maybeText(td.brief_reason);
- const retry = Number(td.retry_count);
- const suffix = Number.isFinite(retry) && retry > 0 ? `,已重试 ${retry} 次` : "";
- return brief ? `判定失败待重试:${brief}${suffix}` : "判定失败,需重试后再判断";
- }
- if (!reason) return "未达到继续扩展要求";
- if (reason.includes("游走次数上限") || reason.includes("次数上限")) return "已达游走次数上限";
- if (reason.includes("复看")) return "分数在待复看区间,未达入池线(先不向外扩展)";
- return reason.replace(/^不适合继续向外扩展:?/, "");
- }
- function cleanBusinessText(value: string): string {
- if (!value || value === "系统原因待补充" || value === "这一步没有记录额外原因") return "";
- return value.replaceAll("“", "《").replaceAll("”", "》");
- }
|